Files
ClaudeDo/src/ClaudeDo.Worker/External/ExternalMcpService.cs
2026-04-25 11:29:58 +02:00

270 lines
10 KiB
C#

using System.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Services;
using ModelContextProtocol.Server;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.External;
public sealed record TaskListDto(string Id, string Name, string? WorkingDir);
public sealed record TagDto(long Id, string Name);
public sealed record TaskDto(
string Id,
string ListId,
string Title,
string? Description,
string Status,
string? Result,
string? CreatedBy,
DateTime CreatedAt,
DateTime? StartedAt,
DateTime? FinishedAt);
[McpServerToolType]
public sealed class ExternalMcpService
{
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
private readonly QueueService _queue;
private readonly HubBroadcaster _broadcaster;
private readonly TagRepository _tags;
public ExternalMcpService(
TaskRepository tasks,
ListRepository lists,
QueueService queue,
HubBroadcaster broadcaster,
TagRepository tags)
{
_tasks = tasks;
_lists = lists;
_queue = queue;
_broadcaster = broadcaster;
_tags = tags;
}
[McpServerTool, Description("List all task lists available in ClaudeDo.")]
public async Task<IReadOnlyList<TaskListDto>> ListTaskLists(CancellationToken cancellationToken)
{
var lists = await _lists.GetAllAsync(cancellationToken);
return lists.Select(l => new TaskListDto(l.Id, l.Name, l.WorkingDir)).ToList();
}
[McpServerTool, Description("List tasks in a given list. Optionally filter by creator (CreatedBy) and/or status.")]
public async Task<IReadOnlyList<TaskDto>> ListTasks(
string listId,
string? createdBy,
string? status,
CancellationToken cancellationToken)
{
TaskStatus? statusFilter = null;
if (!string.IsNullOrWhiteSpace(status))
{
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
throw new InvalidOperationException($"Unknown status '{status}'.");
statusFilter = parsed;
}
var tasks = await _tasks.GetByListIdAsync(listId, cancellationToken);
IEnumerable<TaskEntity> query = tasks;
if (createdBy is not null)
query = query.Where(t => t.CreatedBy == createdBy);
if (statusFilter is not null)
query = query.Where(t => t.Status == statusFilter);
return query.Select(ToDto).ToList();
}
[McpServerTool, Description("Get a single task by id, including its current status and result.")]
public async Task<TaskDto> GetTask(string taskId, CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
return ToDto(task);
}
[McpServerTool, Description("Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution. Optional tags are attached on creation; missing tag names auto-create.")]
public async Task<TaskDto> AddTask(
string listId,
string title,
string? description,
string createdBy,
bool queueImmediately,
IReadOnlyList<string>? tags,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(listId))
throw new InvalidOperationException("listId is required.");
if (string.IsNullOrWhiteSpace(title))
throw new InvalidOperationException("title is required.");
if (string.IsNullOrWhiteSpace(createdBy))
throw new InvalidOperationException("createdBy is required.");
var list = await _lists.GetByIdAsync(listId, cancellationToken)
?? throw new InvalidOperationException($"List {listId} not found.");
var entity = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = title,
Description = description,
Status = queueImmediately ? TaskStatus.Queued : TaskStatus.Manual,
CreatedAt = DateTime.UtcNow,
CommitType = list.DefaultCommitType,
CreatedBy = createdBy,
};
await _tasks.AddAsync(entity, cancellationToken);
if (tags is not null && tags.Count > 0)
await _tasks.SetTagsAsync(entity.Id, tags, cancellationToken);
if (queueImmediately)
_queue.WakeQueue();
await _broadcaster.TaskUpdated(entity.Id);
return ToDto(entity);
}
[McpServerTool, Description("Update an existing task's title, description, commit type, and/or tags. Pass null to leave a field unchanged. Tags are replaced as a full set when non-null. Refuses if the task is currently Running.")]
public async Task<TaskDto> UpdateTask(
string taskId,
string? title,
string? description,
string? commitType,
IReadOnlyList<string>? tags,
CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status == TaskStatus.Running)
throw new InvalidOperationException("Cannot update a running task. Cancel it first.");
if (title is not null) task.Title = title;
if (description is not null) task.Description = description;
if (commitType is not null) task.CommitType = commitType;
await _tasks.UpdateAsync(task, cancellationToken);
if (tags is not null)
await _tasks.SetTagsAsync(taskId, tags, cancellationToken);
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
await _broadcaster.TaskUpdated(taskId);
return ToDto(reload);
}
[McpServerTool, Description("Update a task's status. Only 'Manual' and 'Queued' are permitted — use RunTaskNow or CancelTask for execution control.")]
public async Task<TaskDto> UpdateTaskStatus(
string taskId,
string status,
CancellationToken cancellationToken)
{
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var target))
throw new InvalidOperationException($"Unknown status '{status}'.");
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
switch (target)
{
case TaskStatus.Manual:
await _tasks.ResetToManualAsync(taskId, cancellationToken);
break;
case TaskStatus.Queued:
if (task.Status is TaskStatus.Running)
throw new InvalidOperationException("Cannot enqueue a running task.");
task.Status = TaskStatus.Queued;
await _tasks.UpdateAsync(task, cancellationToken);
_queue.WakeQueue();
break;
default:
throw new InvalidOperationException(
$"Status '{target}' is not settable externally. Use RunTaskNow or CancelTask.");
}
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
await _broadcaster.TaskUpdated(taskId);
return ToDto(reload);
}
[McpServerTool, Description("Immediately run a task in the override execution slot (bypasses the agent queue).")]
public async Task RunTaskNow(string taskId, CancellationToken cancellationToken)
{
try
{
await _queue.RunNow(taskId);
}
catch (InvalidOperationException)
{
throw new InvalidOperationException("Override slot busy. Try again later.");
}
catch (KeyNotFoundException)
{
throw new InvalidOperationException($"Task {taskId} not found.");
}
await _broadcaster.TaskUpdated(taskId);
}
[McpServerTool, Description("Cancel a running task. Returns true if the task was running and cancellation was requested.")]
public async Task<bool> CancelTask(string taskId, CancellationToken cancellationToken)
{
var cancelled = _queue.CancelTask(taskId);
if (cancelled) await _broadcaster.TaskUpdated(taskId);
return cancelled;
}
[McpServerTool, Description("Delete a task. Refuses if the task is currently Running — cancel it first.")]
public async Task DeleteTask(string taskId, CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status == TaskStatus.Running)
throw new InvalidOperationException("Cannot delete a running task. Cancel it first.");
await _tasks.DeleteAsync(taskId, cancellationToken);
await _broadcaster.TaskUpdated(taskId);
}
[McpServerTool, Description("Replace the full tag set on an existing task. Missing tag names auto-create. Refuses if the task is Running.")]
public async Task<TaskDto> SetTaskTags(
string taskId,
IReadOnlyList<string> tags,
CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status == TaskStatus.Running)
throw new InvalidOperationException("Cannot retag a running task. Cancel it first.");
await _tasks.SetTagsAsync(taskId, tags, cancellationToken);
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
await _broadcaster.TaskUpdated(taskId);
return ToDto(reload);
}
[McpServerTool, Description("List all known tags. Useful for discovering existing tag names (including 'agent' which marks tasks for auto-execution) before tagging.")]
public async Task<IReadOnlyList<TagDto>> ListTags(CancellationToken cancellationToken)
{
var tags = await _tags.GetAllAsync(cancellationToken);
return tags.Select(t => new TagDto(t.Id, t.Name)).ToList();
}
private static TaskDto ToDto(TaskEntity t) => new(
t.Id,
t.ListId,
t.Title,
t.Description,
t.Status.ToString(),
t.Result,
t.CreatedBy,
t.CreatedAt,
t.StartedAt,
t.FinishedAt);
}