diff --git a/src/ClaudeDo.Worker/Config/WorkerConfig.cs b/src/ClaudeDo.Worker/Config/WorkerConfig.cs
index 6ef4b0d..7f35b24 100644
--- a/src/ClaudeDo.Worker/Config/WorkerConfig.cs
+++ b/src/ClaudeDo.Worker/Config/WorkerConfig.cs
@@ -31,6 +31,14 @@ public sealed class WorkerConfig
[JsonPropertyName("claude_bin")]
public string ClaudeBin { get; set; } = "claude";
+ /// Port for the external MCP endpoint. 0 disables the external listener entirely.
+ [JsonPropertyName("external_mcp_port")]
+ public int ExternalMcpPort { get; set; } = 47_822;
+
+ /// Optional API key clients must pass via X-ClaudeDo-Key header. Null/empty = loopback trust only.
+ [JsonPropertyName("external_mcp_api_key")]
+ public string? ExternalMcpApiKey { get; set; }
+
public static string DefaultConfigPath =>
Path.Combine(Paths.AppDataRoot(), "worker.config.json");
diff --git a/src/ClaudeDo.Worker/External/ExternalMcpAuthMiddleware.cs b/src/ClaudeDo.Worker/External/ExternalMcpAuthMiddleware.cs
new file mode 100644
index 0000000..38b90ff
--- /dev/null
+++ b/src/ClaudeDo.Worker/External/ExternalMcpAuthMiddleware.cs
@@ -0,0 +1,32 @@
+using ClaudeDo.Worker.Config;
+using Microsoft.AspNetCore.Http;
+
+namespace ClaudeDo.Worker.External;
+
+public sealed class ExternalMcpAuthMiddleware
+{
+ private const string HeaderName = "X-ClaudeDo-Key";
+
+ private readonly RequestDelegate _next;
+
+ public ExternalMcpAuthMiddleware(RequestDelegate next) => _next = next;
+
+ public async Task InvokeAsync(HttpContext ctx, WorkerConfig cfg)
+ {
+ if (string.IsNullOrEmpty(cfg.ExternalMcpApiKey))
+ {
+ await _next(ctx);
+ return;
+ }
+
+ var provided = ctx.Request.Headers[HeaderName].ToString();
+ if (!string.Equals(provided, cfg.ExternalMcpApiKey, StringComparison.Ordinal))
+ {
+ ctx.Response.StatusCode = 401;
+ await ctx.Response.WriteAsync($"Missing or invalid {HeaderName} header");
+ return;
+ }
+
+ await _next(ctx);
+ }
+}
diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs
new file mode 100644
index 0000000..bbe13ed
--- /dev/null
+++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs
@@ -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> 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> ListTasks(
+ string listId,
+ string? createdBy,
+ string? status,
+ CancellationToken cancellationToken)
+ {
+ TaskStatus? statusFilter = null;
+ if (!string.IsNullOrWhiteSpace(status))
+ {
+ if (!Enum.TryParse(status, ignoreCase: true, out var parsed))
+ throw new InvalidOperationException($"Unknown status '{status}'.");
+ statusFilter = parsed;
+ }
+
+ var tasks = await _tasks.GetByListIdAsync(listId, cancellationToken);
+ IEnumerable 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 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 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 UpdateTaskStatus(
+ string taskId,
+ string status,
+ CancellationToken cancellationToken)
+ {
+ if (!Enum.TryParse(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 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);
+}
diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs
index cefec7e..cfb768e 100644
--- a/src/ClaudeDo.Worker/Program.cs
+++ b/src/ClaudeDo.Worker/Program.cs
@@ -2,6 +2,7 @@ using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
+using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Runner;
@@ -72,6 +73,7 @@ builder.Services.AddScoped();
builder.Services.AddScoped(sp =>
sp.GetRequiredService>().CreateDbContext());
builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddMcpServer()
.WithHttpTransport()
@@ -108,4 +110,44 @@ app.MapMcp("/mcp");
app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})",
cfg.SignalRPort, cfg.DbPath);
-app.Run();
+// Build the external MCP endpoint as a separate WebApplication on its own port.
+// Rationale: ModelContextProtocol.AspNetCore registers one server per DI container,
+// so we need a second app to expose a different tool set under different auth.
+// Shared singletons (QueueService, HubBroadcaster, WorkerConfig, db factory) are
+// injected by instance so both apps operate on the same runtime state.
+WebApplication? externalApp = null;
+if (cfg.ExternalMcpPort > 0)
+{
+ var externalBuilder = WebApplication.CreateBuilder();
+ externalBuilder.Services.AddSingleton(cfg);
+ externalBuilder.Services.AddSingleton(app.Services.GetRequiredService());
+ externalBuilder.Services.AddSingleton(app.Services.GetRequiredService());
+ externalBuilder.Services.AddSingleton(app.Services.GetRequiredService>());
+ externalBuilder.Services.AddScoped(sp =>
+ sp.GetRequiredService>().CreateDbContext());
+ externalBuilder.Services.AddScoped();
+ externalBuilder.Services.AddScoped();
+ externalBuilder.Services.AddScoped();
+ externalBuilder.Services.AddMcpServer()
+ .WithHttpTransport()
+ .WithTools();
+ externalBuilder.WebHost.UseUrls($"http://127.0.0.1:{cfg.ExternalMcpPort}");
+
+ externalApp = externalBuilder.Build();
+ externalApp.UseMiddleware();
+ externalApp.MapMcp("/mcp");
+
+ externalApp.Logger.LogInformation(
+ "ClaudeDo.Worker external MCP listening on http://127.0.0.1:{Port} (auth: {Auth})",
+ cfg.ExternalMcpPort,
+ string.IsNullOrEmpty(cfg.ExternalMcpApiKey) ? "loopback-only" : "X-ClaudeDo-Key");
+}
+
+if (externalApp is null)
+{
+ await app.RunAsync();
+}
+else
+{
+ await Task.WhenAll(app.RunAsync(), externalApp.RunAsync());
+}