feat(worker): add external MCP endpoint with API-key auth
A second WebApplication runs the external MCP server on its own port (default 47822) so it can expose a different tool set under different auth than the internal /mcp endpoint. Shared singletons (config, broadcaster, queue, db factory) are injected by instance so both apps share runtime state. ExternalMcpAuthMiddleware enforces an optional X-ClaudeDo-Key header; loopback-only trust when no key is configured. Tools: ListTaskLists, ListTasks, GetTask, AddTask, UpdateTaskStatus, RunTaskNow, CancelTask. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
197
src/ClaudeDo.Worker/External/ExternalMcpService.cs
vendored
Normal file
197
src/ClaudeDo.Worker/External/ExternalMcpService.cs
vendored
Normal file
@@ -0,0 +1,197 @@
|
||||
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 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;
|
||||
|
||||
public ExternalMcpService(
|
||||
TaskRepository tasks,
|
||||
ListRepository lists,
|
||||
QueueService queue,
|
||||
HubBroadcaster broadcaster)
|
||||
{
|
||||
_tasks = tasks;
|
||||
_lists = lists;
|
||||
_queue = queue;
|
||||
_broadcaster = broadcaster;
|
||||
}
|
||||
|
||||
[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.")]
|
||||
public async Task<TaskDto> AddTask(
|
||||
string listId,
|
||||
string title,
|
||||
string? description,
|
||||
string createdBy,
|
||||
bool queueImmediately,
|
||||
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 (queueImmediately)
|
||||
_queue.WakeQueue();
|
||||
|
||||
await _broadcaster.TaskUpdated(entity.Id);
|
||||
return ToDto(entity);
|
||||
}
|
||||
|
||||
[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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user