Files
ClaudeDo/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs
T
mika kuns c792765ed3 refactor(mcp): rewrite external MCP tool descriptions for trigger clarity
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.
2026-08-07 09:25:45 +02:00

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);
}
}