Files
ClaudeDo/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs
T
mika kuns 2a3133efad feat(worker): accept #123 task numbers as MCP tool input
TaskIdResolver resolves a #123/bare-123 taskId parameter to its GUID
before any lookup, across every External/ MCP tool that takes a task
id, including the batch tools' id arrays (via delegation to the
already-resolving single-entity methods) and update_task's
dependsOnTaskId (empty string still passes through unchanged as the
clear-link sentinel). An unknown number throws a clear error instead
of a silent null. McpToolDocs.TaskNumberHint tells the agent to refer
to tasks as #<number> when reporting to the user, added to the
description of get_task, list_tasks, add_task, update_task_status and
review_task.
2026-08-11 13:10:49 +02:00

120 lines
4.7 KiB
C#

using System.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
public sealed record RunDto(
string Id, int RunNumber, string? SessionId, bool IsRetry,
string? ResultMarkdown, string? StructuredOutputJson, string? ErrorMarkdown,
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
{
private readonly TaskRunRepository _runs;
private readonly TaskRepository _tasks;
public RunHistoryMcpTools(TaskRunRepository runs, TaskRepository tasks)
{
_runs = runs;
_tasks = tasks;
}
[McpServerTool, Description(
"List all execution runs for a task — metadata, tokens, turns, result, and error per run — ordered " +
"oldest to newest by run number, so the last entry is the most recent. Use a run's id from here with " +
"get_run to fetch it individually.")]
public async Task<IReadOnlyList<RunDto>> ListRuns(string taskId, CancellationToken cancellationToken)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var runs = await _runs.GetByTaskIdAsync(taskId, cancellationToken);
return runs.Select(ToDto).ToList();
}
[McpServerTool, Description("Get one execution run's full detail by its run id, obtained from list_runs.")]
public async Task<RunDto> GetRun(string runId, CancellationToken cancellationToken)
{
var run = await _runs.GetByIdAsync(runId, cancellationToken)
?? throw new InvalidOperationException($"Run {runId} not found.");
return ToDto(run);
}
[McpServerTool, Description(
"Fetch NDJSON log lines from a task's latest run — use this to check progress or debug a task without " +
"opening the log file. Defaults to the last 50 lines. available=false means no log exists yet (queued " +
"or just started — not an error); truncated=true when fewer entries are returned than totalLines.")]
public async Task<TaskLogResult> GetTaskLog(
string taskId,
[Description("Number of trailing entries to return; ignored if offset or limit is set. Default 50.")]
int? tail = null,
[Description("0-based entry index to start from; overrides tail when set. Combine with limit to page through the log.")]
int? offset = null,
[Description("Max entries to return starting at offset. Omit to return everything from offset to the end.")]
int? limit = null,
CancellationToken cancellationToken = default)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
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);
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 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(
r.Id, r.RunNumber, r.SessionId, r.IsRetry,
r.ResultMarkdown, r.StructuredOutputJson, r.ErrorMarkdown,
r.ExitCode, r.TurnCount, r.TokensIn, r.TokensOut,
r.StartedAt, r.FinishedAt);
}