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:
mika kuns
2026-07-23 18:21:46 +02:00
parent 3d668da65c
commit d7ebafd556
8 changed files with 176 additions and 18 deletions
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.");
+1
View File
@@ -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>()
@@ -0,0 +1,48 @@
using ClaudeDo.Worker.External;
using ModelContextProtocol;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.Tests.External;
public sealed class ExternalMcpExceptionFilterTests
{
[Fact]
public async Task Wrap_TranslatesInvalidOperationException_PreservingMessage()
{
McpRequestHandler<CallToolRequestParams, CallToolResult> next =
(_, _) => throw new InvalidOperationException("Task abc123 not found.");
var wrapped = ExternalMcpExceptionFilter.Wrap(next);
var ex = await Assert.ThrowsAsync<McpException>(
() => wrapped(null!, CancellationToken.None).AsTask());
Assert.Equal("Task abc123 not found.", ex.Message);
}
[Fact]
public async Task Wrap_TranslatesArgumentException_PreservingMessage()
{
McpRequestHandler<CallToolRequestParams, CallToolResult> next =
(_, _) => throw new ArgumentException("Unknown model alias 'gpt4'.");
var wrapped = ExternalMcpExceptionFilter.Wrap(next);
var ex = await Assert.ThrowsAsync<McpException>(
() => wrapped(null!, CancellationToken.None).AsTask());
Assert.Equal("Unknown model alias 'gpt4'.", ex.Message);
}
[Fact]
public async Task Wrap_PassesThroughSuccessfulResult()
{
var expected = new CallToolResult();
McpRequestHandler<CallToolRequestParams, CallToolResult> next =
(_, _) => ValueTask.FromResult(expected);
var wrapped = ExternalMcpExceptionFilter.Wrap(next);
var result = await wrapped(null!, CancellationToken.None);
Assert.Same(expected, result);
}
}
@@ -0,0 +1,74 @@
using System.Reflection;
using ClaudeDo.Worker.External;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.Tests.External;
/// <summary>
/// MCP clients routinely omit optional arguments. The generated tool schema only marks a
/// parameter optional when the C# method declares a default value — nullability alone is not
/// enough (verified against ModelContextProtocol/Microsoft.Extensions.AI 1.2.0 / 10.4.1's
/// AIJsonUtilities.CreateFunctionJsonSchema, which checks ParameterInfo.IsOptional). This sweeps
/// every [McpServerToolType] class in the External/ namespace so a future tool can't reintroduce
/// a nullable-but-required filter parameter.
/// </summary>
public sealed class ExternalMcpToolSchemaTests
{
private static IEnumerable<MethodInfo> ExternalToolMethods()
{
var toolTypes = typeof(ExternalMcpService).Assembly.GetTypes()
.Where(t => t.Namespace == typeof(ExternalMcpService).Namespace
&& t.GetCustomAttribute<McpServerToolTypeAttribute>() is not null);
foreach (var type in toolTypes)
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
{
if (method.GetCustomAttribute<McpServerToolAttribute>() is not null)
yield return method;
}
}
[Fact]
public void NoExternalTool_HasARequiredNullableParameter()
{
var nullabilityContext = new NullabilityInfoContext();
var violations = new List<string>();
foreach (var method in ExternalToolMethods())
{
var schema = AIJsonUtilities.CreateFunctionJsonSchema(method);
var required = schema.TryGetProperty("required", out var requiredElement)
? requiredElement.EnumerateArray().Select(e => e.GetString()).ToHashSet()
: new HashSet<string?>();
foreach (var parameter in method.GetParameters())
{
if (parameter.ParameterType == typeof(CancellationToken)) continue;
if (!required.Contains(parameter.Name)) continue;
var isNullableValueType = Nullable.GetUnderlyingType(parameter.ParameterType) is not null;
var isNullableRefType = !parameter.ParameterType.IsValueType
&& nullabilityContext.Create(parameter).WriteState == NullabilityState.Nullable;
if (isNullableValueType || isNullableRefType)
{
violations.Add(
$"{method.DeclaringType!.Name}.{method.Name}({parameter.Name}) is a nullable " +
"type but has no default value, so MCP clients omitting it will fail. " +
"Give it a default value (e.g. '= null').");
}
}
}
Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations));
}
[Fact]
public void ExternalToolMethods_AreDiscovered()
{
// Guards the sweep itself: if this drops to 0, ExternalToolMethods() broke silently
// (e.g. namespace/attribute mismatch) and the schema test above would pass vacuously.
Assert.True(ExternalToolMethods().Count() > 20);
}
}