Every tool description now leads with what the tool does AND when to reach for it, since MCP clients rank tools by that text. Per-parameter prose moved onto the parameters as [Description], exhaustive result-shape enumerations and design/history rationale dropped, and the repeated boilerplate clauses (lean-task-ref, batch cap, refused-while-Running) pulled into McpToolDocs, which also documents the style for future tools. Tool-level description text: 20494 -> 13605 chars (-34%); combined with the new parameter descriptions 18517 (-10%). Closes gaps that caused wrong calls rather than just verbose ones: - list_task_attachments returns metadata only, no file content - run_task_now shares continue_task's single override slot and throws when busy - list_runs is ordered oldest-first and feeds get_run - workingDir on create_list/update_list is an existing local git repo path, unvalidated until the first task run - get_task_worktree's behind=0 also means the main ref was unreachable Removes get_task_status_values: a whole tool entry for static reference text. GetTask's description is now the canonical place for status meanings.
121 lines
4.9 KiB
C#
121 lines
4.9 KiB
C#
using System.ComponentModel;
|
|
using System.Text;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Hub;
|
|
using ModelContextProtocol.Server;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.External;
|
|
|
|
public sealed record AttachmentDto(string FileName, long ByteSize, DateTime CreatedAt);
|
|
public sealed record RemoveAttachmentResult(bool Removed, string TaskId, string FileName);
|
|
|
|
[McpServerToolType]
|
|
public sealed class AttachmentMcpTools
|
|
{
|
|
private readonly TaskRepository _tasks;
|
|
private readonly TaskAttachmentRepository _attachments;
|
|
private readonly AttachmentStore _store;
|
|
private readonly HubBroadcaster _broadcaster;
|
|
|
|
public AttachmentMcpTools(
|
|
TaskRepository tasks,
|
|
TaskAttachmentRepository attachments,
|
|
AttachmentStore store,
|
|
HubBroadcaster broadcaster)
|
|
{
|
|
_tasks = tasks;
|
|
_attachments = attachments;
|
|
_store = store;
|
|
_broadcaster = broadcaster;
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Attach a read-only reference file to a task so the agent receives it at run time — use to prepare " +
|
|
"context (plans, scripts, specs) for a task that will run later. Exactly one of textContent/" +
|
|
"base64Content is required. Re-attaching the same fileName overwrites the previous version." +
|
|
McpToolDocs.NotWhileRunning)]
|
|
public async Task<AttachmentDto> AddTaskAttachment(
|
|
string taskId,
|
|
[Description("Name to store the attachment under; reusing an existing name overwrites it.")] string fileName,
|
|
[Description("Plain-text content (plans, markdown, scripts). Provide this or base64Content, not both.")] string? textContent = null,
|
|
[Description("Base64-encoded content for binary files (images, archives). Provide this or textContent, not both.")] string? base64Content = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
var task = await _tasks.GetByIdAsync(taskId, ct)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
if (task.Status == TaskStatus.Running)
|
|
throw new InvalidOperationException("Cannot add an attachment to a running task. Cancel it first.");
|
|
|
|
if (textContent is null == base64Content is null)
|
|
throw new InvalidOperationException(
|
|
"Exactly one of textContent or base64Content must be provided, not both and not neither.");
|
|
|
|
byte[] bytes;
|
|
if (textContent is not null)
|
|
{
|
|
bytes = Encoding.UTF8.GetBytes(textContent);
|
|
}
|
|
else
|
|
{
|
|
try { bytes = Convert.FromBase64String(base64Content!); }
|
|
catch (FormatException ex)
|
|
{
|
|
throw new InvalidOperationException("base64Content is not valid Base64.", ex);
|
|
}
|
|
}
|
|
|
|
using var ms = new MemoryStream(bytes);
|
|
var byteSize = await _store.SaveAsync(taskId, fileName, ms, ct);
|
|
|
|
var existing = await _attachments.GetAsync(taskId, fileName, ct);
|
|
if (existing is not null)
|
|
{
|
|
existing.ByteSize = byteSize;
|
|
await _attachments.UpdateAsync(existing, ct);
|
|
}
|
|
else
|
|
{
|
|
await _attachments.AddAsync(new TaskAttachmentEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
TaskId = taskId,
|
|
FileName = fileName,
|
|
ByteSize = byteSize,
|
|
CreatedAt = DateTime.UtcNow,
|
|
}, ct);
|
|
}
|
|
|
|
await _broadcaster.TaskUpdated(taskId);
|
|
return new AttachmentDto(fileName, byteSize, existing?.CreatedAt ?? DateTime.UtcNow);
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"List all attachments on a task — use to check what reference files are already attached before adding more.")]
|
|
public async Task<IReadOnlyList<AttachmentDto>> ListTaskAttachments(
|
|
string taskId, CancellationToken ct = default)
|
|
{
|
|
var rows = await _attachments.ListByTaskIdAsync(taskId, ct);
|
|
return rows.Select(r => new AttachmentDto(r.FileName, r.ByteSize, r.CreatedAt)).ToList();
|
|
}
|
|
|
|
[McpServerTool, Description(
|
|
"Remove a single attachment from a task, deleting both the file on disk and its database record." +
|
|
McpToolDocs.NotWhileRunning)]
|
|
public async Task<RemoveAttachmentResult> RemoveTaskAttachment(
|
|
string taskId, string fileName, CancellationToken ct = default)
|
|
{
|
|
var task = await _tasks.GetByIdAsync(taskId, ct)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
if (task.Status == TaskStatus.Running)
|
|
throw new InvalidOperationException("Cannot remove an attachment from a running task. Cancel it first.");
|
|
|
|
_store.DeleteFile(taskId, fileName);
|
|
await _attachments.DeleteAsync(taskId, fileName, ct);
|
|
await _broadcaster.TaskUpdated(taskId);
|
|
return new RemoveAttachmentResult(true, taskId, fileName);
|
|
}
|
|
}
|