fix(worker): make external MCP filter params optional, surface tool errors
Nullable filter/patch params across the External/ MCP tool classes (ListTasks, UpdateTask, AddSubtask, ReviewTask, SetMyDay, SetListConfig/SetTaskConfig, CreateList/UpdateList) lacked C# default values, so the generated tool schema marked them required — MCP clients omitting them (the common case) failed. Gave every such parameter a default value. Also registered a call-tool filter (ExternalMcpExceptionFilter) on the external MCP host that translates InvalidOperationException/ArgumentException into McpException, since the SDK's own catch-all discards ex.Message for any other exception type and returns a generic "An error occurred invoking 'X'." string. Added a reflection-based schema test sweeping every [McpServerToolType] class to guard against reintroducing a required-but-nullable parameter.
This commit is contained in:
@@ -32,7 +32,7 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `
|
||||
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL` and schedule; QueueService is a thin `BackgroundService` that loops on the waker and dispatches via `TaskRunner`.
|
||||
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
|
||||
- **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
|
||||
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`. Organized by concern:
|
||||
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`. Every optional/filter parameter across these tools must carry a C# default value (e.g. `string? status = null`) — the MCP schema only marks a parameter optional when it has one; nullability alone doesn't do it (`ExternalMcpToolSchemaTests` guards this by reflection). `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so `InvalidOperationException`/`ArgumentException` messages survive as `McpException` — otherwise the SDK's own catch-all replaces any non-`McpException` with a generic "An error occurred invoking 'X'." Organized by concern:
|
||||
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued`), `GetTaskStatusValues`, `ReviewTask` (`approve` / `reject_rerun` / `reject_park` / `cancel` for a WaitingForReview task), `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`; worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ListWorktrees`, `CleanupTaskWorktree`
|
||||
- `BatchMcpTools` — best-effort batch variants that loop the `ExternalMcpService` single-entity methods (sequential — the scoped DbContext is not thread-safe; merge/review stay single-task): `BatchGetTasks`, `BatchAddTasks`, `BatchUpdateTaskStatus`, `BatchCancelTasks`, `BatchDeleteTasks`, `BatchSetMyDay`, `BatchCleanupTaskWorktrees`. Every tool returns a per-item result array ({ id/index, ok, error?, … }) — a failing item never aborts the rest — and rejects batches over 100 items.
|
||||
- `ListMcpTools` — `CreateList`, `UpdateList`, `DeleteList`
|
||||
|
||||
+4
-2
@@ -31,7 +31,8 @@ public sealed class ConfigMcpTools
|
||||
|
||||
[McpServerTool, Description("Set a list's default model/system prompt/agent path/max turns. Passing all four as null clears the list config.")]
|
||||
public async Task SetListConfig(
|
||||
string listId, string? model, string? systemPrompt, string? agentPath, int? maxTurns, CancellationToken cancellationToken)
|
||||
string listId, string? model = null, string? systemPrompt = null, string? agentPath = null,
|
||||
int? maxTurns = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = await _lists.GetByIdAsync(listId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"List {listId} not found.");
|
||||
@@ -53,7 +54,8 @@ public sealed class ConfigMcpTools
|
||||
|
||||
[McpServerTool, Description("Set per-task config overrides (model/system prompt/agent path/max turns). Pass null for any field to clear that override.")]
|
||||
public async Task SetTaskConfig(
|
||||
string taskId, string? model, string? systemPrompt, string? agentPath, int? maxTurns, CancellationToken cancellationToken)
|
||||
string taskId, string? model = null, string? systemPrompt = null, string? agentPath = null,
|
||||
int? maxTurns = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using ModelContextProtocol;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
/// <summary>
|
||||
/// The MCP SDK's own call-tool catch-all only preserves ex.Message for <see cref="McpException"/> —
|
||||
/// any other exception type is replaced with a generic "An error occurred invoking 'X'." with no detail.
|
||||
/// This filter translates the expected validation exceptions thrown across the External/ tool classes
|
||||
/// (task/list not found, bad status, unknown model, etc.) into McpException so callers see why a call failed.
|
||||
/// </summary>
|
||||
public static class ExternalMcpExceptionFilter
|
||||
{
|
||||
public static McpRequestHandler<CallToolRequestParams, CallToolResult> Wrap(
|
||||
McpRequestHandler<CallToolRequestParams, CallToolResult> next) =>
|
||||
async (request, cancellationToken) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return await next(request, cancellationToken);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
throw new McpException(ex.Message, ex);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
throw new McpException(ex.Message, ex);
|
||||
}
|
||||
};
|
||||
}
|
||||
+13
-13
@@ -107,9 +107,9 @@ public sealed class ExternalMcpService
|
||||
"Valid status values: Idle, Queued, Running, WaitingForReview, WaitingForChildren, Done, Failed, Cancelled.")]
|
||||
public async Task<IReadOnlyList<TaskDto>> ListTasks(
|
||||
string listId,
|
||||
string? createdBy,
|
||||
string? status,
|
||||
CancellationToken cancellationToken)
|
||||
string? createdBy = null,
|
||||
string? status = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
TaskStatus? statusFilter = null;
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
@@ -193,10 +193,10 @@ public sealed class ExternalMcpService
|
||||
[McpServerTool, Description("Update an existing task's title, description, and/or commit type. Pass null to leave a field unchanged. Refuses if the task is currently Running.")]
|
||||
public async Task<TaskDto> UpdateTask(
|
||||
string taskId,
|
||||
string? title,
|
||||
string? description,
|
||||
string? commitType,
|
||||
CancellationToken cancellationToken)
|
||||
string? title = null,
|
||||
string? description = null,
|
||||
string? commitType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
@@ -219,8 +219,8 @@ public sealed class ExternalMcpService
|
||||
public async Task<TaskDto> AddSubtask(
|
||||
string taskId,
|
||||
string title,
|
||||
int? orderNum,
|
||||
CancellationToken cancellationToken)
|
||||
int? orderNum = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
throw new InvalidOperationException("title is required.");
|
||||
@@ -300,8 +300,8 @@ public sealed class ExternalMcpService
|
||||
public async Task<TaskDto> ReviewTask(
|
||||
string taskId,
|
||||
string decision,
|
||||
string? feedback,
|
||||
CancellationToken cancellationToken)
|
||||
string? feedback = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
@@ -619,8 +619,8 @@ public sealed class ExternalMcpService
|
||||
public async Task<TaskDto> SetMyDay(
|
||||
string taskId,
|
||||
bool isMyDay,
|
||||
int? sortOrder,
|
||||
CancellationToken cancellationToken)
|
||||
int? sortOrder = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
|
||||
+3
-2
@@ -22,7 +22,7 @@ public sealed class ListMcpTools
|
||||
|
||||
[McpServerTool, Description("Create a new task list. workingDir sets the git repo tasks run against; commitType defaults to 'chore'.")]
|
||||
public async Task<ListSummaryDto> CreateList(
|
||||
string name, string? workingDir, string? commitType, CancellationToken cancellationToken)
|
||||
string name, string? workingDir = null, string? commitType = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new InvalidOperationException("name is required.");
|
||||
@@ -42,7 +42,8 @@ public sealed class ListMcpTools
|
||||
|
||||
[McpServerTool, Description("Rename a list and/or change its working dir and default commit type. Pass null to leave a field unchanged.")]
|
||||
public async Task<ListSummaryDto> UpdateList(
|
||||
string listId, string? name, string? workingDir, string? commitType, CancellationToken cancellationToken)
|
||||
string listId, string? name = null, string? workingDir = null, string? commitType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _lists.GetByIdAsync(listId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"List {listId} not found.");
|
||||
|
||||
@@ -282,6 +282,7 @@ if (cfg.ExternalMcpPort > 0)
|
||||
externalBuilder.Services.AddScoped<AttachmentMcpTools>();
|
||||
externalBuilder.Services.AddMcpServer()
|
||||
.WithHttpTransport()
|
||||
.WithRequestFilters(f => f.AddCallToolFilter(ExternalMcpExceptionFilter.Wrap))
|
||||
.WithTools<ExternalMcpService>()
|
||||
.WithTools<BatchMcpTools>()
|
||||
.WithTools<ListMcpTools>()
|
||||
|
||||
Reference in New Issue
Block a user