From edae693e82c6a0c301423d10a597a329310a88a7 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Thu, 4 Jun 2026 14:20:16 -0300 Subject: [PATCH] refactor(api): route HabitTools via AgentOperationExecutor + add 6 IAiTools (#79) MCP habit mutations now run through McpExecutorBridge -> IAgentOperationExecutor (Surface=Mcp), so they share the agent policy layer (read-only-credential denial, ownership pre-check, confirmation gating) and the AgentAuditLogs trail instead of calling IMediator.Send directly. Reads stay on MediatR. bulk_log_habits and bulk_skip_habits stay on MediatR because their MCP contract supports an explicit per-instance date the chat IAiTools do not model. Authors the 6 previously-unbacked habit IAiTools (update_checklist, reorder_habits, move_habit_parent, link_goals_to_habit, bulk_create_habits, bulk_delete_habits), registers them in DI, and maps them in AgentCatalogService so the build-time catalog enforcement passes. Overlaps wave-2 #89, which needs the same 6 tools. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Extensions/ServiceCollectionExtensions.cs | 7 + src/Orbit.Api/Mcp/McpExecutorBridge.cs | 81 ++++ src/Orbit.Api/Mcp/Tools/HabitTools.cs | 330 ++++++++-------- .../Implementations/BulkCreateHabitsTool.cs | 143 +++++++ .../Implementations/BulkDeleteHabitsTool.cs | 47 +++ .../Implementations/LinkGoalsToHabitTool.cs | 49 +++ .../Implementations/MoveHabitParentTool.cs | 47 +++ .../Implementations/ReorderHabitsTool.cs | 62 +++ .../Implementations/UpdateChecklistTool.cs | 59 +++ .../Services/AgentCatalogService.cs | 5 +- .../Mcp/HabitToolsTests.cs | 354 +++++++++++------- .../McpHabitExecutorRoutingTests.cs | 152 ++++++++ 12 files changed, 1027 insertions(+), 309 deletions(-) create mode 100644 src/Orbit.Api/Mcp/McpExecutorBridge.cs create mode 100644 src/Orbit.Application/Chat/Tools/Implementations/BulkCreateHabitsTool.cs create mode 100644 src/Orbit.Application/Chat/Tools/Implementations/BulkDeleteHabitsTool.cs create mode 100644 src/Orbit.Application/Chat/Tools/Implementations/LinkGoalsToHabitTool.cs create mode 100644 src/Orbit.Application/Chat/Tools/Implementations/MoveHabitParentTool.cs create mode 100644 src/Orbit.Application/Chat/Tools/Implementations/ReorderHabitsTool.cs create mode 100644 src/Orbit.Application/Chat/Tools/Implementations/UpdateChecklistTool.cs create mode 100644 tests/Orbit.IntegrationTests/McpHabitExecutorRoutingTests.cs diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs index a283f1e0..9d7d0bbd 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs @@ -148,6 +148,7 @@ public static WebApplicationBuilder AddOrbitAiServices(this WebApplicationBuilde builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); // AI Tool Registration builder.Services.AddScoped(); @@ -196,6 +197,12 @@ public static WebApplicationBuilder AddOrbitAiServices(this WebApplicationBuilde builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/src/Orbit.Api/Mcp/McpExecutorBridge.cs b/src/Orbit.Api/Mcp/McpExecutorBridge.cs new file mode 100644 index 00000000..aaa1e566 --- /dev/null +++ b/src/Orbit.Api/Mcp/McpExecutorBridge.cs @@ -0,0 +1,81 @@ +using System.Security.Claims; +using System.Text.Json; +using System.Text.Json.Serialization; +using Orbit.Api.Extensions; +using Orbit.Domain.Interfaces; +using Orbit.Domain.Models; + +namespace Orbit.Api.Mcp; + +/// +/// Routes MCP tool mutations through the shared so they +/// pass the same policy evaluation and AgentAuditLogs trail as every other agent surface. +/// Serializes a caller-supplied snake_case argument object into the the +/// backing IAiTool expects, builds the request with +/// and the four claim-derived credential fields, and maps the executor outcome back to the legacy +/// MCP string contract: callers format their own success message; denials/failures become +/// "Error: …"; pending-confirmation outcomes return a deterministic confirmation prompt. +/// +public class McpExecutorBridge(IAgentOperationExecutor operationExecutor) +{ + private static readonly JsonSerializerOptions ArgumentSerializerOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public async Task ExecuteAsync( + ClaimsPrincipal user, + string operationId, + object arguments, + string? confirmationToken, + CancellationToken cancellationToken) + { + var argumentsElement = JsonSerializer.SerializeToElement(arguments, ArgumentSerializerOptions); + + var response = await operationExecutor.ExecuteAsync(new AgentExecuteOperationRequest( + user.GetUserId(), + operationId, + argumentsElement, + AgentExecutionSurface.Mcp, + user.GetAgentAuthMethod(), + user.GetGrantedAgentScopes(), + user.IsReadOnlyCredential(), + confirmationToken), cancellationToken); + + return Map(response.Operation); + } + + private static McpExecutorResult Map(AgentOperationResult operation) => operation.Status switch + { + AgentOperationStatus.Succeeded => new McpExecutorResult( + operation.Status, operation.TargetId, operation.TargetName, operation.Payload, Message: string.Empty), + AgentOperationStatus.PendingConfirmation => new McpExecutorResult( + operation.Status, operation.TargetId, operation.TargetName, operation.Payload, + Message: BuildPendingMessage(operation)), + _ => new McpExecutorResult( + operation.Status, operation.TargetId, operation.TargetName, operation.Payload, + Message: $"Error: {operation.PolicyReason ?? operation.Summary ?? "operation failed"}") + }; + + private static string BuildPendingMessage(AgentOperationResult operation) + { + var pendingId = operation.PendingOperationId?.ToString() ?? "unknown"; + return $"Confirmation required before this action runs. Confirm pending operation {pendingId} " + + "via confirm_agent_operation_v2, then retry with the returned confirmation token."; + } +} + +/// +/// Outcome of an MCP mutation routed through . On success the caller +/// formats its own message from //; +/// otherwise carries the ready-to-return error or confirmation string. +/// +public record McpExecutorResult( + AgentOperationStatus Status, + string? TargetId, + string? TargetName, + object? Payload, + string Message) +{ + public bool Succeeded => Status == AgentOperationStatus.Succeeded; +} diff --git a/src/Orbit.Api/Mcp/Tools/HabitTools.cs b/src/Orbit.Api/Mcp/Tools/HabitTools.cs index ed0ae012..2720e475 100644 --- a/src/Orbit.Api/Mcp/Tools/HabitTools.cs +++ b/src/Orbit.Api/Mcp/Tools/HabitTools.cs @@ -1,8 +1,8 @@ using System.ComponentModel; -using System.Globalization; using System.Security.Claims; using MediatR; using ModelContextProtocol.Server; +using Orbit.Api.Mcp; using Orbit.Application.Common; using Orbit.Application.Habits.Commands; using Orbit.Application.Habits.Queries; @@ -12,10 +12,24 @@ namespace Orbit.Api.Mcp.Tools; +/// +/// MCP habit tools. Mutations route through → +/// with +/// , so they share the policy +/// evaluation (read-only-credential denial, ownership pre-check, confirmation gating) and the +/// AgentAuditLogs trail used by every other agent surface; each mutation forwards a +/// snake_case argument object matching its backing IAiTool schema and formats the +/// returned into the legacy string contract. Read/query tools +/// stay on MediatR. Other MCP toolsets mirror this routing for the same policy + audit coverage. +/// +/// bulk_log_habits and bulk_skip_habits intentionally stay on MediatR: their MCP +/// contract accepts an explicit per-instance date that the current chat IAiTools do not +/// model, so routing them would silently drop that capability. +/// +/// [McpServerToolType] -public class HabitTools(IMediator mediator, IUserDateService userDateService) +public class HabitTools(IMediator mediator, IUserDateService userDateService, McpExecutorBridge executorBridge) { - private static readonly System.Text.Json.JsonSerializerOptions CaseInsensitiveJsonOptions = new() { PropertyNameCaseInsensitive = true }; private const string HabitIdDescription = "The habit ID (GUID)"; private const string DateFromDescription = "Start date in YYYY-MM-DD format"; private const string DateToDescription = "End date in YYYY-MM-DD format"; @@ -107,26 +121,25 @@ public async Task CreateHabit( [Description("Due time in HH:mm format")] string? dueTime = null, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - FrequencyUnit? freqUnit = frequencyUnit is not null ? Enum.Parse(frequencyUnit, true) : null; - - var command = new CreateHabitCommand( - userId, + var result = await executorBridge.ExecuteAsync(user, "create_habit", new + { title, + due_date = dueDate, description, - freqUnit, - frequencyQuantity, - IsBadHabit: isBadHabit, - DueDate: McpInputParser.ParseDate(dueDate, "dueDate"), - IsGeneral: isGeneral, - Options: new HabitCommandOptions( - DueTime: McpInputParser.ParseOptionalTime(dueTime, "dueTime"), - IsFlexible: isFlexible)); - - var result = await mediator.Send(command, cancellationToken); - return result.IsSuccess - ? $"Created habit '{title}' (id: {result.Value})" - : $"Error: {result.Error}"; + frequency_unit = frequencyUnit, + frequency_quantity = frequencyQuantity, + is_bad_habit = isBadHabit, + is_general = isGeneral, + is_flexible = isFlexible, + due_time = dueTime + }, confirmationToken: null, cancellationToken); + + if (!result.Succeeded) + return result.Message; + + return result.TargetId is not null + ? $"Created habit '{title}' (id: {result.TargetId})" + : $"More detail needed before creating '{title}': specify a frequency (e.g. Day, Week) or mark it as a one-time task."; } [McpServerTool(Name = "update_habit"), Description("Update an existing habit's properties. Title is required (pass current title if unchanged). Only change fields you need to modify.")] @@ -142,38 +155,33 @@ public async Task UpdateHabit( [Description("New due time in HH:mm format")] string? dueTime = null, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - FrequencyUnit? freqUnit = frequencyUnit is not null ? Enum.Parse(frequencyUnit, true) : null; - - var command = new UpdateHabitCommand( - userId, - McpInputParser.ParseGuid(habitId, "habitId"), + var result = await executorBridge.ExecuteAsync(user, "update_habit", new + { + habit_id = habitId, title, description, - freqUnit, - frequencyQuantity, - DueDate: McpInputParser.ParseOptionalDate(dueDate, "dueDate"), - Options: new UpdateHabitCommandOptions( - DueTime: McpInputParser.ParseOptionalTime(dueTime, "dueTime"))); + frequency_unit = frequencyUnit, + frequency_quantity = frequencyQuantity, + due_date = dueDate, + due_time = dueTime + }, confirmationToken: null, cancellationToken); - var result = await mediator.Send(command, cancellationToken); - return result.IsSuccess - ? $"Updated habit {habitId}" - : $"Error: {result.Error}"; + return result.Succeeded ? $"Updated habit {habitId}" : result.Message; } [McpServerTool(Name = "delete_habit"), Description("Delete a habit by ID.")] public async Task DeleteHabit( ClaimsPrincipal user, [Description(HabitIdDescription)] string habitId, + [Description("Confirmation token returned by confirm_agent_operation_v2 (required: deleting a habit is destructive)")] string? confirmationToken = null, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var command = new DeleteHabitCommand(userId, McpInputParser.ParseGuid(habitId, "habitId")); - var result = await mediator.Send(command, cancellationToken); - return result.IsSuccess - ? $"Deleted habit {habitId}" - : $"Error: {result.Error}"; + var result = await executorBridge.ExecuteAsync(user, "delete_habit", new + { + habit_id = habitId + }, confirmationToken, cancellationToken); + + return result.Succeeded ? $"Deleted habit {habitId}" : result.Message; } [McpServerTool(Name = "log_habit"), Description("Log a habit as completed (or toggle it off if already logged today).")] @@ -183,16 +191,13 @@ public async Task LogHabit( [Description("Date to log for in YYYY-MM-DD format (defaults to today)")] string? date = null, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var command = new LogHabitCommand( - userId, - McpInputParser.ParseGuid(habitId, "habitId"), - McpInputParser.ParseOptionalDate(date, "date")); + var result = await executorBridge.ExecuteAsync(user, "log_habit", new + { + habit_id = habitId, + date + }, confirmationToken: null, cancellationToken); - var result = await mediator.Send(command, cancellationToken); - return result.IsSuccess - ? $"Logged habit {habitId} (log id: {result.Value})" - : $"Error: {result.Error}"; + return result.Succeeded ? $"Logged habit {habitId}" : result.Message; } [McpServerTool(Name = "get_habit_metrics"), Description("Get streak, completion rate, and other metrics for a habit.")] @@ -225,16 +230,13 @@ public async Task SkipHabit( [Description("Date to skip in YYYY-MM-DD format (defaults to today)")] string? date = null, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var command = new SkipHabitCommand( - userId, - McpInputParser.ParseGuid(habitId, "habitId"), - McpInputParser.ParseOptionalDate(date, "date")); + var result = await executorBridge.ExecuteAsync(user, "skip_habit", new + { + habit_id = habitId, + date + }, confirmationToken: null, cancellationToken); - var result = await mediator.Send(command, cancellationToken); - return result.IsSuccess - ? $"Skipped habit {habitId}" - : $"Error: {result.Error}"; + return result.Succeeded ? $"Skipped habit {habitId}" : result.Message; } [McpServerTool(Name = "update_checklist"), Description("Update the checklist items for a habit. Pass the full list of items with their checked state.")] @@ -244,17 +246,15 @@ public async Task UpdateChecklist( [Description("JSON array of checklist items, each with 'text' (string) and 'isChecked' (boolean)")] string checklistItemsJson, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var items = System.Text.Json.JsonSerializer.Deserialize>( - checklistItemsJson, - CaseInsensitiveJsonOptions) - ?? []; + var items = DeserializeJson>(checklistItemsJson) ?? []; - var command = new UpdateChecklistCommand(userId, McpInputParser.ParseGuid(habitId, "habitId"), items); - var result = await mediator.Send(command, cancellationToken); - return result.IsSuccess - ? $"Updated checklist for habit {habitId} ({items.Count} items)" - : $"Error: {result.Error}"; + var result = await executorBridge.ExecuteAsync(user, "update_checklist", new + { + habit_id = habitId, + checklist_items = items.Select(item => new { text = item.Text, is_checked = item.IsChecked }) + }, confirmationToken: null, cancellationToken); + + return result.Succeeded ? $"Updated checklist for habit {habitId} ({items.Count} items)" : result.Message; } [McpServerTool(Name = "get_habit_logs"), Description("Get completion logs for a specific habit (last 365 days).")] @@ -320,26 +320,22 @@ public async Task CreateSubHabit( [Description("Due date override in YYYY-MM-DD format")] string? dueDate = null, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - FrequencyUnit? freqUnit = frequencyUnit is not null ? Enum.Parse(frequencyUnit, true) : null; - - var command = new CreateSubHabitCommand( - userId, - McpInputParser.ParseGuid(parentHabitId, "parentHabitId"), + var result = await executorBridge.ExecuteAsync(user, "create_sub_habit", new + { + parent_habit_id = parentHabitId, title, description, - freqUnit, - frequencyQuantity, - IsBadHabit: isBadHabit, - DueDate: McpInputParser.ParseOptionalDate(dueDate, "dueDate"), - Options: new HabitCommandOptions( - DueTime: McpInputParser.ParseOptionalTime(dueTime, "dueTime"), - IsFlexible: isFlexible)); - - var result = await mediator.Send(command, cancellationToken); - return result.IsSuccess - ? $"Created sub-habit '{title}' (id: {result.Value}) under parent {parentHabitId}" - : $"Error: {result.Error}"; + frequency_unit = frequencyUnit, + frequency_quantity = frequencyQuantity, + due_time = dueTime, + is_bad_habit = isBadHabit, + is_flexible = isFlexible, + due_date = dueDate + }, confirmationToken: null, cancellationToken); + + return result.Succeeded + ? $"Created sub-habit '{title}' (id: {result.TargetId}) under parent {parentHabitId}" + : result.Message; } [McpServerTool(Name = "duplicate_habit"), Description("Duplicate a habit (including its sub-habits and tags).")] @@ -348,37 +344,37 @@ public async Task DuplicateHabit( [Description("The habit ID to duplicate (GUID)")] string habitId, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var command = new DuplicateHabitCommand(userId, McpInputParser.ParseGuid(habitId, "habitId")); - var result = await mediator.Send(command, cancellationToken); - return result.IsSuccess - ? $"Duplicated habit {habitId} (new id: {result.Value})" - : $"Error: {result.Error}"; + var result = await executorBridge.ExecuteAsync(user, "duplicate_habit", new + { + habit_id = habitId + }, confirmationToken: null, cancellationToken); + + return result.Succeeded ? $"Duplicated habit {habitId} (new id: {result.TargetId})" : result.Message; } [McpServerTool(Name = "bulk_create_habits"), Description("Create multiple habits at once. Each habit can have sub-habits.")] public async Task BulkCreateHabits( ClaimsPrincipal user, [Description("JSON array of habit objects. Each with: title (required), description, frequencyUnit (Day/Week/Month/Year), frequencyQuantity, isBadHabit, dueDate (YYYY-MM-DD), dueTime (HH:mm), isGeneral, isFlexible")] string habitsJson, + [Description("Confirmation token returned by confirm_agent_operation_v2 (required: bulk create is a destructive batch operation)")] string? confirmationToken = null, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var items = System.Text.Json.JsonSerializer.Deserialize>( - habitsJson, - CaseInsensitiveJsonOptions) - ?? []; + var parsedHabits = DeserializeJson>(habitsJson) ?? []; - var bulkItems = items.Select(MapToBulkHabitItem).ToList(); - var command = new BulkCreateHabitsCommand(userId, bulkItems); - var result = await mediator.Send(command, cancellationToken); + var result = await executorBridge.ExecuteAsync(user, "bulk_create_habits", new + { + habits = parsedHabits.Select(ToBulkHabitArgs) + }, confirmationToken, cancellationToken); - if (result.IsFailure) - return $"Error: {result.Error}"; + if (!result.Succeeded) + return result.Message; - var r = result.Value; - var successCount = r.Results.Count(x => x.Status == BulkItemStatus.Success); - var failCount = r.Results.Count(x => x.Status == BulkItemStatus.Failed); - var lines = r.Results.Select(x => + if (result.Payload is not BulkCreateResult bulk) + return $"Bulk create: {result.TargetName}"; + + var successCount = bulk.Results.Count(x => x.Status == BulkItemStatus.Success); + var failCount = bulk.Results.Count(x => x.Status == BulkItemStatus.Failed); + var lines = bulk.Results.Select(x => x.Status == BulkItemStatus.Success ? $"- OK: {x.Title} (id: {x.HabitId})" : $"- FAILED: {x.Title} - {x.Error}"); @@ -390,20 +386,23 @@ public async Task BulkCreateHabits( public async Task BulkDeleteHabits( ClaimsPrincipal user, [Description("Comma-separated habit IDs (GUIDs)")] string habitIds, + [Description("Confirmation token returned by confirm_agent_operation_v2 (required: bulk delete is destructive)")] string? confirmationToken = null, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var ids = habitIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(Guid.Parse).ToList(); + var ids = ParseGuidCsv(habitIds); - var command = new BulkDeleteHabitsCommand(userId, ids); - var result = await mediator.Send(command, cancellationToken); + var result = await executorBridge.ExecuteAsync(user, "bulk_delete_habits", new + { + habit_ids = ids.Select(id => id.ToString()) + }, confirmationToken, cancellationToken); - if (result.IsFailure) - return $"Error: {result.Error}"; + if (!result.Succeeded) + return result.Message; - var r = result.Value; - var successCount = r.Results.Count(x => x.Status == BulkItemStatus.Success); + if (result.Payload is not BulkDeleteResult bulk) + return $"Bulk delete: {result.TargetName}"; + + var successCount = bulk.Results.Count(x => x.Status == BulkItemStatus.Success); return $"Bulk delete: {successCount}/{ids.Count} deleted successfully"; } @@ -461,18 +460,14 @@ public async Task ReorderHabits( [Description("JSON array of objects with 'habitId' (GUID) and 'position' (int)")] string positionsJson, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var items = System.Text.Json.JsonSerializer.Deserialize>( - positionsJson, - CaseInsensitiveJsonOptions) - ?? []; + var positions = DeserializeJson>(positionsJson) ?? []; - var positions = items.Select(p => new HabitPositionUpdate(McpInputParser.ParseGuid(p.HabitId, "habitId"), p.Position)).ToList(); - var command = new ReorderHabitsCommand(userId, positions); - var result = await mediator.Send(command, cancellationToken); - return result.IsSuccess - ? $"Reordered {positions.Count} habits" - : $"Error: {result.Error}"; + var result = await executorBridge.ExecuteAsync(user, "reorder_habits", new + { + positions = positions.Select(p => new { habit_id = p.HabitId, position = p.Position }) + }, confirmationToken: null, cancellationToken); + + return result.Succeeded ? $"Reordered {positions.Count} habits" : result.Message; } [McpServerTool(Name = "move_habit_parent"), Description("Move a habit under a different parent, or promote it to top-level by passing null parentId.")] @@ -482,15 +477,14 @@ public async Task MoveHabitParent( [Description("New parent habit ID (GUID), or omit/null to make top-level")] string? parentId = null, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var command = new MoveHabitParentCommand( - userId, - McpInputParser.ParseGuid(habitId, "habitId"), - McpInputParser.ParseOptionalGuid(parentId, "parentId")); + var result = await executorBridge.ExecuteAsync(user, "move_habit_parent", new + { + habit_id = habitId, + parent_id = parentId + }, confirmationToken: null, cancellationToken); - var result = await mediator.Send(command, cancellationToken); - if (!result.IsSuccess) - return $"Error: {result.Error}"; + if (!result.Succeeded) + return result.Message; return parentId is not null ? $"Moved habit {habitId} under parent {parentId}" @@ -504,15 +498,15 @@ public async Task LinkGoalsToHabit( [Description("Comma-separated goal IDs (GUIDs)")] string goalIds, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); - var ids = goalIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(Guid.Parse).ToList(); + var ids = ParseGuidCsv(goalIds); - var command = new LinkGoalsToHabitCommand(userId, McpInputParser.ParseGuid(habitId, "habitId"), ids); - var result = await mediator.Send(command, cancellationToken); - return result.IsSuccess - ? $"Linked {ids.Count} goals to habit {habitId}" - : $"Error: {result.Error}"; + var result = await executorBridge.ExecuteAsync(user, "link_goals_to_habit", new + { + habit_id = habitId, + goal_ids = ids.Select(id => id.ToString()) + }, confirmationToken: null, cancellationToken); + + return result.Succeeded ? $"Linked {ids.Count} goals to habit {habitId}" : result.Message; } [McpServerTool(Name = "get_daily_summary"), Description("Get an AI-generated daily summary of habits. Requires Pro subscription and AI summary enabled.")] @@ -579,7 +573,31 @@ private static Guid GetUserId(ClaimsPrincipal user) return userId; } - // DTOs for JSON deserialization + private static List ParseGuidCsv(string value) => + value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(Guid.Parse) + .ToList(); + + private static readonly System.Text.Json.JsonSerializerOptions CaseInsensitiveJsonOptions = + new() { PropertyNameCaseInsensitive = true }; + + private static T? DeserializeJson(string json) => + System.Text.Json.JsonSerializer.Deserialize(json, CaseInsensitiveJsonOptions); + + private static object ToBulkHabitArgs(BulkHabitItemDto dto) => new + { + title = dto.Title, + description = dto.Description, + frequency_unit = dto.FrequencyUnit, + frequency_quantity = dto.FrequencyQuantity, + is_bad_habit = dto.IsBadHabit, + due_date = dto.DueDate, + due_time = dto.DueTime, + is_general = dto.IsGeneral, + is_flexible = dto.IsFlexible, + sub_habits = dto.SubHabits?.Select(ToBulkHabitArgs) + }; + private sealed record BulkHabitItemDto( string Title, string? Description = null, @@ -592,6 +610,8 @@ private sealed record BulkHabitItemDto( bool IsFlexible = false, List? SubHabits = null); + private sealed record HabitPositionDto(string HabitId, int Position); + private sealed record HabitLineData( Guid Id, string Title, FrequencyUnit? FreqUnit, int? FreqQty, TimeOnly? DueTime, bool IsCompleted, bool IsOverdue, bool IsBadHabit, @@ -625,24 +645,4 @@ private static void AppendChildren(List lines, IReadOnlyList(dto.FrequencyUnit, true) : null; - - return new BulkHabitItem( - dto.Title, - dto.Description, - freqUnit, - dto.FrequencyQuantity, - IsBadHabit: dto.IsBadHabit, - DueDate: McpInputParser.ParseOptionalDate(dto.DueDate, "dueDate"), - DueTime: McpInputParser.ParseOptionalTime(dto.DueTime, "dueTime"), - IsGeneral: dto.IsGeneral, - IsFlexible: dto.IsFlexible, - SubHabits: dto.SubHabits?.Select(MapToBulkHabitItem).ToList()); - } } diff --git a/src/Orbit.Application/Chat/Tools/Implementations/BulkCreateHabitsTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/BulkCreateHabitsTool.cs new file mode 100644 index 00000000..e4095b90 --- /dev/null +++ b/src/Orbit.Application/Chat/Tools/Implementations/BulkCreateHabitsTool.cs @@ -0,0 +1,143 @@ +using System.Text.Json; +using MediatR; +using Orbit.Application.Habits.Commands; + +namespace Orbit.Application.Chat.Tools.Implementations; + +public class BulkCreateHabitsTool( + IMediator mediator) : IAiTool +{ + private const string TitleProperty = "title"; + + public string Name => "bulk_create_habits"; + + public string Description => + "Create multiple habits in a single operation. Each habit may contain its own sub-habits."; + + public object GetParameterSchema() => new + { + type = JsonSchemaTypes.Object, + properties = new + { + habits = new + { + type = JsonSchemaTypes.Array, + description = "Habits to create.", + items = HabitItemSchema(includeSubHabits: true) + } + }, + required = new[] { "habits" } + }; + + public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) + { + if (!args.TryGetProperty("habits", out var habitsEl) || habitsEl.ValueKind != JsonValueKind.Array) + return new ToolResult(false, Error: "habits is required and must be an array."); + + var items = new List(); + foreach (var habitEl in habitsEl.EnumerateArray()) + { + var item = ParseBulkHabitItem(habitEl); + if (item is null) + return new ToolResult(false, Error: "Each habit requires a non-empty title."); + items.Add(item); + } + + if (items.Count == 0) + return new ToolResult(false, Error: "No habits provided."); + + var result = await mediator.Send(new BulkCreateHabitsCommand(userId, items), ct); + + if (result.IsFailure) + return new ToolResult(false, Error: result.Error); + + var successCount = result.Value.Results.Count(item => item.Status == BulkItemStatus.Success); + return new ToolResult(true, EntityName: $"{successCount}/{items.Count} habits created", Payload: result.Value); + } + + private static BulkHabitItem? ParseBulkHabitItem(JsonElement el) + { + var title = JsonArgumentParser.GetOptionalString(el, TitleProperty); + if (string.IsNullOrWhiteSpace(title)) + return null; + + List? subHabits = null; + if (el.TryGetProperty("sub_habits", out var subEl) && subEl.ValueKind == JsonValueKind.Array) + { + subHabits = new List(); + foreach (var child in subEl.EnumerateArray()) + { + var parsedChild = ParseBulkHabitItem(child); + if (parsedChild is not null) + subHabits.Add(parsedChild); + } + } + + return new BulkHabitItem( + title, + JsonArgumentParser.GetOptionalString(el, "description"), + JsonArgumentParser.ParseFrequencyUnit(el), + JsonArgumentParser.GetOptionalInt(el, "frequency_quantity"), + Days: JsonArgumentParser.ParseDays(el), + IsBadHabit: JsonArgumentParser.GetOptionalBool(el, "is_bad_habit") ?? false, + DueDate: JsonArgumentParser.ParseDateOnly(el, "due_date"), + DueTime: JsonArgumentParser.ParseTimeOnly(el, "due_time"), + IsGeneral: JsonArgumentParser.GetOptionalBool(el, "is_general") ?? false, + EndDate: JsonArgumentParser.ParseDateOnly(el, "end_date"), + IsFlexible: JsonArgumentParser.GetOptionalBool(el, "is_flexible") ?? false, + ChecklistItems: JsonArgumentParser.ParseChecklistItems(el), + SubHabits: subHabits, + Emoji: JsonArgumentParser.GetOptionalString(el, "emoji")); + } + + private static object HabitItemSchema(bool includeSubHabits) + { + var properties = new Dictionary + { + [TitleProperty] = new { type = JsonSchemaTypes.String, description = "Name of the habit" }, + ["description"] = new { type = JsonSchemaTypes.String, description = "Optional description" }, + ["emoji"] = new { type = JsonSchemaTypes.String, description = "Emoji icon. Pick a relevant one when clear.", nullable = true }, + ["frequency_unit"] = new { type = JsonSchemaTypes.String, description = "Recurrence unit. Omit for one-time tasks.", nullable = true, @enum = JsonSchemaTypes.FrequencyUnitEnum }, + ["frequency_quantity"] = new { type = JsonSchemaTypes.Integer, description = "How often. Defaults to 1." }, + ["days"] = new { type = JsonSchemaTypes.Array, description = "Specific weekdays. Only when frequency_quantity is 1.", items = new { type = JsonSchemaTypes.String } }, + ["due_date"] = new { type = JsonSchemaTypes.String, description = "YYYY-MM-DD. Defaults to today." }, + ["end_date"] = new { type = JsonSchemaTypes.String, description = "YYYY-MM-DD. Optional end date.", nullable = true }, + ["due_time"] = new { type = JsonSchemaTypes.String, description = "HH:mm 24h format" }, + ["is_bad_habit"] = new { type = JsonSchemaTypes.Boolean, description = "Whether this is a bad habit to reduce. Defaults to false." }, + ["is_general"] = new { type = JsonSchemaTypes.Boolean, description = "Whether this is a general habit with no schedule. Defaults to false." }, + ["is_flexible"] = new { type = JsonSchemaTypes.Boolean, description = "Window-based tracking. Requires frequency_unit." }, + ["checklist_items"] = new + { + type = JsonSchemaTypes.Array, + description = "Atomic sub-steps done together in one execution.", + items = new + { + type = JsonSchemaTypes.Object, + properties = new + { + text = new { type = JsonSchemaTypes.String, description = "Checklist item text" }, + is_checked = new { type = JsonSchemaTypes.Boolean, description = "Whether checked. Defaults to false." } + }, + required = new[] { "text" } + } + } + }; + + if (includeSubHabits) + { + properties["sub_habits"] = new + { + type = JsonSchemaTypes.Array, + description = "Independently trackable child habits under this parent.", + items = HabitItemSchema(includeSubHabits: false) + }; + } + + return new + { + type = JsonSchemaTypes.Object, + properties, + required = new[] { TitleProperty } + }; + } +} diff --git a/src/Orbit.Application/Chat/Tools/Implementations/BulkDeleteHabitsTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/BulkDeleteHabitsTool.cs new file mode 100644 index 00000000..619a7265 --- /dev/null +++ b/src/Orbit.Application/Chat/Tools/Implementations/BulkDeleteHabitsTool.cs @@ -0,0 +1,47 @@ +using System.Text.Json; +using MediatR; +using Orbit.Application.Habits.Commands; + +namespace Orbit.Application.Chat.Tools.Implementations; + +public class BulkDeleteHabitsTool( + IMediator mediator) : IAiTool +{ + public string Name => "bulk_delete_habits"; + + public string Description => + "Permanently delete multiple habits in a single operation. Only delete habits the user explicitly asked to remove."; + + public object GetParameterSchema() => new + { + type = JsonSchemaTypes.Object, + properties = new + { + habit_ids = new + { + type = JsonSchemaTypes.Array, + description = "IDs of the habits to delete.", + items = new { type = JsonSchemaTypes.String } + } + }, + required = new[] { "habit_ids" } + }; + + public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) + { + if (!args.TryGetProperty("habit_ids", out var idsEl) || idsEl.ValueKind != JsonValueKind.Array) + return new ToolResult(false, Error: "habit_ids is required and must be an array."); + + var habitIds = JsonArgumentParser.ParseGuidArray(args, "habit_ids") ?? new List(); + if (habitIds.Count == 0) + return new ToolResult(false, Error: "No valid habit IDs provided."); + + var result = await mediator.Send(new BulkDeleteHabitsCommand(userId, habitIds), ct); + + if (result.IsFailure) + return new ToolResult(false, Error: result.Error); + + var successCount = result.Value.Results.Count(item => item.Status == BulkItemStatus.Success); + return new ToolResult(true, EntityName: $"{successCount}/{habitIds.Count} habits deleted", Payload: result.Value); + } +} diff --git a/src/Orbit.Application/Chat/Tools/Implementations/LinkGoalsToHabitTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/LinkGoalsToHabitTool.cs new file mode 100644 index 00000000..4c95b6da --- /dev/null +++ b/src/Orbit.Application/Chat/Tools/Implementations/LinkGoalsToHabitTool.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using MediatR; +using Orbit.Application.Habits.Commands; + +namespace Orbit.Application.Chat.Tools.Implementations; + +public class LinkGoalsToHabitTool( + IMediator mediator) : IAiTool +{ + public string Name => "link_goals_to_habit"; + + public string Description => + "Link one or more goals to a habit. Replaces all existing goal links for the habit. Pass the full desired list of goal IDs."; + + public object GetParameterSchema() => new + { + type = JsonSchemaTypes.Object, + properties = new + { + habit_id = new { type = JsonSchemaTypes.String, description = "ID of the habit to link goals to" }, + goal_ids = new + { + type = JsonSchemaTypes.Array, + description = "IDs of goals to link to the habit. Replaces all existing links.", + items = new { type = JsonSchemaTypes.String } + } + }, + required = new[] { "habit_id", "goal_ids" } + }; + + public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) + { + if (!args.TryGetProperty("habit_id", out var habitIdEl) || + !Guid.TryParse(habitIdEl.GetString(), out var habitId)) + return new ToolResult(false, Error: "habit_id is required and must be a valid GUID."); + + if (!args.TryGetProperty("goal_ids", out var goalIdsEl) || goalIdsEl.ValueKind != JsonValueKind.Array) + return new ToolResult(false, Error: "goal_ids is required and must be an array."); + + var goalIds = JsonArgumentParser.ParseGuidArray(args, "goal_ids") ?? new List(); + + var result = await mediator.Send(new LinkGoalsToHabitCommand(userId, habitId, goalIds), ct); + + if (result.IsFailure) + return new ToolResult(false, Error: result.Error); + + return new ToolResult(true, EntityId: habitId.ToString()); + } +} diff --git a/src/Orbit.Application/Chat/Tools/Implementations/MoveHabitParentTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/MoveHabitParentTool.cs new file mode 100644 index 00000000..1c8a0c48 --- /dev/null +++ b/src/Orbit.Application/Chat/Tools/Implementations/MoveHabitParentTool.cs @@ -0,0 +1,47 @@ +using System.Text.Json; +using MediatR; +using Orbit.Application.Habits.Commands; + +namespace Orbit.Application.Chat.Tools.Implementations; + +public class MoveHabitParentTool( + IMediator mediator) : IAiTool +{ + public string Name => "move_habit_parent"; + + public string Description => + "Re-parent a habit under a different parent habit, or promote it to top-level by passing a null parent_id."; + + public object GetParameterSchema() => new + { + type = JsonSchemaTypes.Object, + properties = new + { + habit_id = new { type = JsonSchemaTypes.String, description = "ID of the habit to move" }, + parent_id = new { type = JsonSchemaTypes.String, description = "ID of the new parent habit, or null to promote to top-level", nullable = true } + }, + required = new[] { "habit_id" } + }; + + public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) + { + if (!args.TryGetProperty("habit_id", out var habitIdEl) || + !Guid.TryParse(habitIdEl.GetString(), out var habitId)) + return new ToolResult(false, Error: "habit_id is required and must be a valid GUID."); + + Guid? parentId = null; + if (args.TryGetProperty("parent_id", out var parentEl) && parentEl.ValueKind == JsonValueKind.String) + { + if (!Guid.TryParse(parentEl.GetString(), out var parsedParentId)) + return new ToolResult(false, Error: "parent_id must be a valid GUID when provided."); + parentId = parsedParentId; + } + + var result = await mediator.Send(new MoveHabitParentCommand(userId, habitId, parentId), ct); + + if (result.IsFailure) + return new ToolResult(false, Error: result.Error); + + return new ToolResult(true, EntityId: habitId.ToString()); + } +} diff --git a/src/Orbit.Application/Chat/Tools/Implementations/ReorderHabitsTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/ReorderHabitsTool.cs new file mode 100644 index 00000000..13697ba5 --- /dev/null +++ b/src/Orbit.Application/Chat/Tools/Implementations/ReorderHabitsTool.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using MediatR; +using Orbit.Application.Habits.Commands; + +namespace Orbit.Application.Chat.Tools.Implementations; + +public class ReorderHabitsTool( + IMediator mediator) : IAiTool +{ + public string Name => "reorder_habits"; + + public string Description => + "Set new display positions for habits. Pass each habit ID with its target zero-based position."; + + public object GetParameterSchema() => new + { + type = JsonSchemaTypes.Object, + properties = new + { + positions = new + { + type = JsonSchemaTypes.Array, + description = "Habit positions to apply.", + items = new + { + type = JsonSchemaTypes.Object, + properties = new + { + habit_id = new { type = JsonSchemaTypes.String, description = "ID of the habit to position" }, + position = new { type = JsonSchemaTypes.Integer, description = "Zero-based target position" } + }, + required = new[] { "habit_id", "position" } + } + } + }, + required = new[] { "positions" } + }; + + public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) + { + if (!args.TryGetProperty("positions", out var positionsEl) || positionsEl.ValueKind != JsonValueKind.Array) + return new ToolResult(false, Error: "positions is required and must be an array."); + + var positions = new List(); + foreach (var item in positionsEl.EnumerateArray()) + { + var habitIdValue = JsonArgumentParser.GetOptionalString(item, "habit_id"); + var position = JsonArgumentParser.GetOptionalInt(item, "position"); + if (habitIdValue is null || position is null || !Guid.TryParse(habitIdValue, out var habitId)) + return new ToolResult(false, Error: "Each position requires a valid habit_id GUID and an integer position."); + + positions.Add(new HabitPositionUpdate(habitId, position.Value)); + } + + var result = await mediator.Send(new ReorderHabitsCommand(userId, positions), ct); + + if (result.IsFailure) + return new ToolResult(false, Error: result.Error); + + return new ToolResult(true, EntityName: $"{positions.Count} habits"); + } +} diff --git a/src/Orbit.Application/Chat/Tools/Implementations/UpdateChecklistTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/UpdateChecklistTool.cs new file mode 100644 index 00000000..4fdafec2 --- /dev/null +++ b/src/Orbit.Application/Chat/Tools/Implementations/UpdateChecklistTool.cs @@ -0,0 +1,59 @@ +using System.Text.Json; +using MediatR; +using Orbit.Application.Habits.Commands; +using Orbit.Domain.ValueObjects; + +namespace Orbit.Application.Chat.Tools.Implementations; + +public class UpdateChecklistTool( + IMediator mediator) : IAiTool +{ + public string Name => "update_checklist"; + + public string Description => + "Replace the checklist items on a habit. Pass the full desired list - existing items are overwritten."; + + public object GetParameterSchema() => new + { + type = JsonSchemaTypes.Object, + properties = new + { + habit_id = new { type = JsonSchemaTypes.String, description = "ID of the habit whose checklist to replace" }, + checklist_items = new + { + type = JsonSchemaTypes.Array, + description = "Full desired list of checklist items. Replaces all existing items.", + items = new + { + type = JsonSchemaTypes.Object, + properties = new + { + text = new { type = JsonSchemaTypes.String, description = "Checklist item text" }, + is_checked = new { type = JsonSchemaTypes.Boolean, description = "Whether checked. Defaults to false." } + }, + required = new[] { "text" } + } + } + }, + required = new[] { "habit_id", "checklist_items" } + }; + + public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) + { + if (!args.TryGetProperty("habit_id", out var habitIdEl) || + !Guid.TryParse(habitIdEl.GetString(), out var habitId)) + return new ToolResult(false, Error: "habit_id is required and must be a valid GUID."); + + if (!args.TryGetProperty("checklist_items", out var itemsEl) || itemsEl.ValueKind != JsonValueKind.Array) + return new ToolResult(false, Error: "checklist_items is required and must be an array."); + + var items = JsonArgumentParser.ParseChecklistItems(args) ?? new List(); + + var result = await mediator.Send(new UpdateChecklistCommand(userId, habitId, items), ct); + + if (result.IsFailure) + return new ToolResult(false, Error: result.Error); + + return new ToolResult(true, EntityId: habitId.ToString()); + } +} diff --git a/src/Orbit.Infrastructure/Services/AgentCatalogService.cs b/src/Orbit.Infrastructure/Services/AgentCatalogService.cs index c457d825..10c54576 100644 --- a/src/Orbit.Infrastructure/Services/AgentCatalogService.cs +++ b/src/Orbit.Infrastructure/Services/AgentCatalogService.cs @@ -470,7 +470,7 @@ private static IReadOnlyList BuildCapabilities() isMutation: true, isPhaseOneReadOnly: false, AgentConfirmationRequirement.None, - chatTools: ["create_habit", "update_habit", "bulk_update_habit_emojis", "create_sub_habit", "duplicate_habit", "move_habit", "log_habit", "skip_habit", "suggest_breakdown"], + chatTools: ["create_habit", "update_habit", "bulk_update_habit_emojis", "create_sub_habit", "duplicate_habit", "move_habit", "move_habit_parent", "log_habit", "skip_habit", "suggest_breakdown", "update_checklist", "reorder_habits", "link_goals_to_habit"], mcpTools: [ "create_habit", @@ -522,7 +522,7 @@ private static IReadOnlyList BuildCapabilities() isMutation: true, isPhaseOneReadOnly: false, AgentConfirmationRequirement.FreshConfirmation, - chatTools: ["bulk_log_habits", "bulk_skip_habits"], + chatTools: ["bulk_log_habits", "bulk_skip_habits", "bulk_create_habits"], mcpTools: ["bulk_create_habits", "bulk_log_habits", "bulk_skip_habits"], controllerActions: ["HabitsController.BulkCreate", "HabitsController.BulkLog", "HabitsController.BulkSkip"]), @@ -536,6 +536,7 @@ private static IReadOnlyList BuildCapabilities() isMutation: true, isPhaseOneReadOnly: false, AgentConfirmationRequirement.FreshConfirmation, + chatTools: ["bulk_delete_habits"], mcpTools: ["bulk_delete_habits"], controllerActions: ["HabitsController.BulkDelete"]), diff --git a/tests/Orbit.Infrastructure.Tests/Mcp/HabitToolsTests.cs b/tests/Orbit.Infrastructure.Tests/Mcp/HabitToolsTests.cs index f820dcca..ed4e3816 100644 --- a/tests/Orbit.Infrastructure.Tests/Mcp/HabitToolsTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Mcp/HabitToolsTests.cs @@ -2,6 +2,7 @@ using FluentAssertions; using MediatR; using NSubstitute; +using Orbit.Api.Mcp; using Orbit.Api.Mcp.Tools; using Orbit.Application.Common; using Orbit.Application.Habits.Commands; @@ -10,7 +11,6 @@ using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; using Orbit.Domain.Models; -using Orbit.Domain.ValueObjects; namespace Orbit.Infrastructure.Tests.Mcp; @@ -18,16 +18,47 @@ public class HabitToolsTests { private readonly IMediator _mediator = Substitute.For(); private readonly IUserDateService _userDateService = Substitute.For(); + private readonly IAgentOperationExecutor _executor = Substitute.For(); private readonly HabitTools _tools; private readonly ClaimsPrincipal _user; public HabitToolsTests() { - _tools = new HabitTools(_mediator, _userDateService); + _tools = new HabitTools(_mediator, _userDateService, new McpExecutorBridge(_executor)); var claims = new[] { new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()) }; _user = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); } + private void StubExecutor(AgentOperationStatus status, string? targetId = null, string? targetName = null, + string? policyReason = null, object? payload = null, Guid? pendingOperationId = null) + { + var response = new AgentExecuteOperationResponse(new AgentOperationResult( + "operation", + "operation", + AgentRiskClass.Low, + AgentConfirmationRequirement.None, + status, + Summary: "summary", + TargetId: targetId, + TargetName: targetName, + PolicyReason: policyReason, + PendingOperationId: pendingOperationId, + Payload: payload)); + + _executor.ExecuteAsync(Arg.Any(), Arg.Any()) + .Returns(response); + } + + private async Task CapturedRequestAsync(Func act) + { + await act(); + var calls = _executor.ReceivedCalls() + .Where(call => call.GetMethodInfo().Name == nameof(IAgentOperationExecutor.ExecuteAsync)) + .ToList(); + calls.Should().NotBeEmpty(); + return (AgentExecuteOperationRequest)calls[^1].GetArguments()[0]!; + } + [Fact] public async Task ListHabits_Success_ReturnsFormattedList() { @@ -101,14 +132,17 @@ public async Task GetHabit_Failure_ReturnsError() } [Fact] - public async Task CreateHabit_Success_ReturnsCreatedMessage() + public async Task CreateHabit_Success_RoutesThroughExecutorAndReturnsCreatedMessage() { var newId = Guid.NewGuid(); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success(newId)); + StubExecutor(AgentOperationStatus.Succeeded, targetId: newId.ToString(), targetName: "New Habit"); - var result = await _tools.CreateHabit(_user, "New Habit", "2026-04-01"); + AgentExecuteOperationRequest request = null!; + string result = string.Empty; + request = await CapturedRequestAsync(async () => result = await _tools.CreateHabit(_user, "New Habit", "2026-04-01")); + request.OperationId.Should().Be("create_habit"); + request.Surface.Should().Be(AgentExecutionSurface.Mcp); result.Should().Contain("Created habit 'New Habit'"); result.Should().Contain(newId.ToString()); } @@ -116,8 +150,7 @@ public async Task CreateHabit_Success_ReturnsCreatedMessage() [Fact] public async Task CreateHabit_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Title is required")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Title is required"); var result = await _tools.CreateHabit(_user, "", "2026-04-01"); @@ -125,22 +158,35 @@ public async Task CreateHabit_Failure_ReturnsError() } [Fact] - public async Task UpdateHabit_Success_ReturnsUpdatedMessage() + public async Task CreateHabit_ReadOnlyCredentialDenied_ReturnsError() + { + StubExecutor(AgentOperationStatus.Denied, policyReason: "read_only_credential"); + + var result = await _tools.CreateHabit(_user, "New Habit", "2026-04-01"); + + result.Should().StartWith("Error: "); + result.Should().Contain("read_only_credential"); + } + + [Fact] + public async Task UpdateHabit_Success_RoutesThroughExecutor() { var habitId = Guid.NewGuid(); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.Succeeded, targetId: habitId.ToString()); - var result = await _tools.UpdateHabit(_user, habitId.ToString(), "Updated Title"); + AgentExecuteOperationRequest request = null!; + string result = string.Empty; + request = await CapturedRequestAsync(async () => result = await _tools.UpdateHabit(_user, habitId.ToString(), "Updated Title")); + request.OperationId.Should().Be("update_habit"); + request.Surface.Should().Be(AgentExecutionSurface.Mcp); result.Should().Contain("Updated habit"); } [Fact] public async Task UpdateHabit_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Habit not found")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Habit not found"); var result = await _tools.UpdateHabit(_user, Guid.NewGuid().ToString(), "Title"); @@ -148,22 +194,35 @@ public async Task UpdateHabit_Failure_ReturnsError() } [Fact] - public async Task DeleteHabit_Success_ReturnsDeletedMessage() + public async Task DeleteHabit_Success_RoutesThroughExecutor() { var habitId = Guid.NewGuid(); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.Succeeded, targetId: habitId.ToString()); - var result = await _tools.DeleteHabit(_user, habitId.ToString()); + AgentExecuteOperationRequest request = null!; + string result = string.Empty; + request = await CapturedRequestAsync(async () => result = await _tools.DeleteHabit(_user, habitId.ToString())); + request.OperationId.Should().Be("delete_habit"); + request.Surface.Should().Be(AgentExecutionSurface.Mcp); result.Should().Contain("Deleted habit"); } + [Fact] + public async Task DeleteHabit_PendingConfirmation_ReturnsConfirmationPrompt() + { + StubExecutor(AgentOperationStatus.PendingConfirmation, pendingOperationId: Guid.NewGuid()); + + var result = await _tools.DeleteHabit(_user, Guid.NewGuid().ToString()); + + result.Should().Contain("Confirmation required"); + result.Should().Contain("confirm_agent_operation_v2"); + } + [Fact] public async Task DeleteHabit_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Habit not found")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Habit not found"); var result = await _tools.DeleteHabit(_user, Guid.NewGuid().ToString()); @@ -171,23 +230,24 @@ public async Task DeleteHabit_Failure_ReturnsError() } [Fact] - public async Task LogHabit_Success_ReturnsLoggedMessage() + public async Task LogHabit_Success_RoutesThroughExecutor() { - var logId = Guid.NewGuid(); - var response = new LogHabitResponse(logId, true, 5); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success(response)); + var habitId = Guid.NewGuid(); + StubExecutor(AgentOperationStatus.Succeeded, targetId: habitId.ToString()); - var result = await _tools.LogHabit(_user, Guid.NewGuid().ToString()); + AgentExecuteOperationRequest request = null!; + string result = string.Empty; + request = await CapturedRequestAsync(async () => result = await _tools.LogHabit(_user, habitId.ToString())); + request.OperationId.Should().Be("log_habit"); + request.Surface.Should().Be(AgentExecutionSurface.Mcp); result.Should().Contain("Logged habit"); } [Fact] public async Task LogHabit_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Habit not found")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Habit not found"); var result = await _tools.LogHabit(_user, Guid.NewGuid().ToString()); @@ -195,21 +255,24 @@ public async Task LogHabit_Failure_ReturnsError() } [Fact] - public async Task SkipHabit_Success_ReturnsSkippedMessage() + public async Task SkipHabit_Success_RoutesThroughExecutor() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + var habitId = Guid.NewGuid(); + StubExecutor(AgentOperationStatus.Succeeded, targetId: habitId.ToString()); - var result = await _tools.SkipHabit(_user, Guid.NewGuid().ToString()); + AgentExecuteOperationRequest request = null!; + string result = string.Empty; + request = await CapturedRequestAsync(async () => result = await _tools.SkipHabit(_user, habitId.ToString())); + request.OperationId.Should().Be("skip_habit"); + request.Surface.Should().Be(AgentExecutionSurface.Mcp); result.Should().Contain("Skipped habit"); } [Fact] public async Task SkipHabit_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Not a recurring habit")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Not a recurring habit"); var result = await _tools.SkipHabit(_user, Guid.NewGuid().ToString()); @@ -242,15 +305,17 @@ public async Task GetHabitMetrics_Failure_ReturnsError() } [Fact] - public async Task DuplicateHabit_Success_ReturnsDuplicatedMessage() + public async Task DuplicateHabit_Success_RoutesThroughExecutor() { var newId = Guid.NewGuid(); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success(newId)); + StubExecutor(AgentOperationStatus.Succeeded, targetId: newId.ToString()); var habitId = Guid.NewGuid(); - var result = await _tools.DuplicateHabit(_user, habitId.ToString()); + AgentExecuteOperationRequest request = null!; + string result = string.Empty; + request = await CapturedRequestAsync(async () => result = await _tools.DuplicateHabit(_user, habitId.ToString())); + request.OperationId.Should().Be("duplicate_habit"); result.Should().Contain("Duplicated habit"); result.Should().Contain(newId.ToString()); } @@ -258,8 +323,7 @@ public async Task DuplicateHabit_Success_ReturnsDuplicatedMessage() [Fact] public async Task DuplicateHabit_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Habit not found")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Habit not found"); var result = await _tools.DuplicateHabit(_user, Guid.NewGuid().ToString()); @@ -269,13 +333,15 @@ public async Task DuplicateHabit_Failure_ReturnsError() [Fact] public async Task MoveHabitParent_Success_WithParent_ReturnsMovedMessage() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.Succeeded); var habitId = Guid.NewGuid(); var parentId = Guid.NewGuid(); - var result = await _tools.MoveHabitParent(_user, habitId.ToString(), parentId.ToString()); + AgentExecuteOperationRequest request = null!; + string result = string.Empty; + request = await CapturedRequestAsync(async () => result = await _tools.MoveHabitParent(_user, habitId.ToString(), parentId.ToString())); + request.OperationId.Should().Be("move_habit_parent"); result.Should().Contain("Moved habit"); result.Should().Contain("under parent"); } @@ -283,8 +349,7 @@ public async Task MoveHabitParent_Success_WithParent_ReturnsMovedMessage() [Fact] public async Task MoveHabitParent_Success_NoParent_ReturnsPromotedMessage() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.Succeeded); var result = await _tools.MoveHabitParent(_user, Guid.NewGuid().ToString()); @@ -295,8 +360,7 @@ public async Task MoveHabitParent_Success_NoParent_ReturnsPromotedMessage() [Fact] public async Task MoveHabitParent_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Habit not found")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Habit not found"); var result = await _tools.MoveHabitParent(_user, Guid.NewGuid().ToString()); @@ -304,15 +368,17 @@ public async Task MoveHabitParent_Failure_ReturnsError() } [Fact] - public async Task CreateSubHabit_Success_ReturnsCreatedMessage() + public async Task CreateSubHabit_Success_RoutesThroughExecutor() { var newId = Guid.NewGuid(); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success(newId)); + StubExecutor(AgentOperationStatus.Succeeded, targetId: newId.ToString(), targetName: "Sub Habit"); var parentId = Guid.NewGuid(); - var result = await _tools.CreateSubHabit(_user, parentId.ToString(), "Sub Habit"); + AgentExecuteOperationRequest request = null!; + string result = string.Empty; + request = await CapturedRequestAsync(async () => result = await _tools.CreateSubHabit(_user, parentId.ToString(), "Sub Habit")); + request.OperationId.Should().Be("create_sub_habit"); result.Should().Contain("Created sub-habit 'Sub Habit'"); result.Should().Contain(newId.ToString()); } @@ -320,8 +386,7 @@ public async Task CreateSubHabit_Success_ReturnsCreatedMessage() [Fact] public async Task CreateSubHabit_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Parent not found")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Parent not found"); var result = await _tools.CreateSubHabit(_user, Guid.NewGuid().ToString(), "Sub"); @@ -329,22 +394,23 @@ public async Task CreateSubHabit_Failure_ReturnsError() } [Fact] - public async Task ReorderHabits_Success_ReturnsReorderedMessage() + public async Task ReorderHabits_Success_RoutesThroughExecutor() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); + StubExecutor(AgentOperationStatus.Succeeded); var json = $"[{{\"habitId\":\"{Guid.NewGuid()}\",\"position\":0}}]"; - var result = await _tools.ReorderHabits(_user, json); + AgentExecuteOperationRequest request = null!; + string result = string.Empty; + request = await CapturedRequestAsync(async () => result = await _tools.ReorderHabits(_user, json)); + request.OperationId.Should().Be("reorder_habits"); result.Should().Contain("Reordered 1 habits"); } [Fact] public async Task ReorderHabits_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Invalid positions")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Invalid positions"); var json = "[]"; var result = await _tools.ReorderHabits(_user, json); @@ -353,107 +419,159 @@ public async Task ReorderHabits_Failure_ReturnsError() } [Fact] - public async Task BulkLogHabits_Success_ReturnsBulkLogMessage() + public async Task UpdateChecklist_Success_RoutesThroughExecutor() { - var id = Guid.NewGuid(); - var bulkResult = new BulkLogResult([new BulkLogItemResult(0, BulkItemStatus.Success, id, Guid.NewGuid())]); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success(bulkResult)); + StubExecutor(AgentOperationStatus.Succeeded); - var result = await _tools.BulkLogHabits(_user, id.ToString()); + var json = "[{\"text\":\"Item 1\",\"isChecked\":true}]"; + AgentExecuteOperationRequest request = null!; + string result = string.Empty; + request = await CapturedRequestAsync(async () => result = await _tools.UpdateChecklist(_user, Guid.NewGuid().ToString(), json)); - result.Should().Contain("Bulk log: 1/1 logged successfully"); + request.OperationId.Should().Be("update_checklist"); + result.Should().Contain("Updated checklist"); + result.Should().Contain("1 items"); } [Fact] - public async Task BulkLogHabits_Failure_ReturnsError() + public async Task UpdateChecklist_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Error")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Habit not found"); - var result = await _tools.BulkLogHabits(_user, Guid.NewGuid().ToString()); + var json = "[]"; + var result = await _tools.UpdateChecklist(_user, Guid.NewGuid().ToString(), json); result.Should().StartWith("Error: "); } [Fact] - public async Task BulkSkipHabits_Success_ReturnsBulkSkipMessage() + public async Task LinkGoalsToHabit_Success_RoutesThroughExecutor() { - var id = Guid.NewGuid(); - var bulkResult = new BulkSkipResult([new BulkSkipItemResult(0, BulkItemStatus.Success, id)]); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success(bulkResult)); + StubExecutor(AgentOperationStatus.Succeeded); - var result = await _tools.BulkSkipHabits(_user, id.ToString()); + var goalId = Guid.NewGuid(); + AgentExecuteOperationRequest request = null!; + string result = string.Empty; + request = await CapturedRequestAsync(async () => result = await _tools.LinkGoalsToHabit(_user, Guid.NewGuid().ToString(), goalId.ToString())); - result.Should().Contain("Bulk skip: 1/1 skipped successfully"); + request.OperationId.Should().Be("link_goals_to_habit"); + result.Should().Contain("Linked 1 goals"); } [Fact] - public async Task BulkSkipHabits_Failure_ReturnsError() + public async Task LinkGoalsToHabit_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Error")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Not found"); - var result = await _tools.BulkSkipHabits(_user, Guid.NewGuid().ToString()); + var result = await _tools.LinkGoalsToHabit(_user, Guid.NewGuid().ToString(), Guid.NewGuid().ToString()); result.Should().StartWith("Error: "); } [Fact] - public async Task BulkCreateHabits_Success_ReturnsBulkCreateMessage() + public async Task BulkCreateHabits_Success_RoutesThroughExecutorAndFormatsBreakdown() { var id = Guid.NewGuid(); var bulkResult = new BulkCreateResult([ new BulkCreateItemResult(0, BulkItemStatus.Success, id, "Habit1") ]); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success(bulkResult)); + StubExecutor(AgentOperationStatus.Succeeded, payload: bulkResult); var json = "[{\"title\":\"Habit1\",\"dueDate\":\"2026-04-01\"}]"; - var result = await _tools.BulkCreateHabits(_user, json); + AgentExecuteOperationRequest request = null!; + string result = string.Empty; + request = await CapturedRequestAsync(async () => result = await _tools.BulkCreateHabits(_user, json)); + request.OperationId.Should().Be("bulk_create_habits"); result.Should().Contain("1 succeeded, 0 failed"); } [Fact] public async Task BulkCreateHabits_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Limit exceeded")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Limit exceeded"); - var json = "[]"; + var json = "[{\"title\":\"Habit1\",\"dueDate\":\"2026-04-01\"}]"; var result = await _tools.BulkCreateHabits(_user, json); result.Should().StartWith("Error: "); } [Fact] - public async Task BulkDeleteHabits_Success_ReturnsBulkDeleteMessage() + public async Task BulkDeleteHabits_Success_RoutesThroughExecutor() { var id = Guid.NewGuid(); var bulkResult = new BulkDeleteResult([ new BulkDeleteItemResult(0, BulkItemStatus.Success, id) ]); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success(bulkResult)); + StubExecutor(AgentOperationStatus.Succeeded, payload: bulkResult); - var result = await _tools.BulkDeleteHabits(_user, id.ToString()); + AgentExecuteOperationRequest request = null!; + string result = string.Empty; + request = await CapturedRequestAsync(async () => result = await _tools.BulkDeleteHabits(_user, id.ToString())); + request.OperationId.Should().Be("bulk_delete_habits"); result.Should().Contain("1/1 deleted successfully"); } [Fact] public async Task BulkDeleteHabits_Failure_ReturnsError() { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Error")); + StubExecutor(AgentOperationStatus.Failed, policyReason: "Error"); var result = await _tools.BulkDeleteHabits(_user, Guid.NewGuid().ToString()); result.Should().StartWith("Error: "); } + [Fact] + public async Task BulkLogHabits_Success_ReturnsBulkLogMessage() + { + var id = Guid.NewGuid(); + var bulkResult = new BulkLogResult([new BulkLogItemResult(0, BulkItemStatus.Success, id, Guid.NewGuid())]); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Success(bulkResult)); + + var result = await _tools.BulkLogHabits(_user, id.ToString()); + + result.Should().Contain("Bulk log: 1/1 logged successfully"); + } + + [Fact] + public async Task BulkLogHabits_Failure_ReturnsError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Failure("Error")); + + var result = await _tools.BulkLogHabits(_user, Guid.NewGuid().ToString()); + + result.Should().StartWith("Error: "); + } + + [Fact] + public async Task BulkSkipHabits_Success_ReturnsBulkSkipMessage() + { + var id = Guid.NewGuid(); + var bulkResult = new BulkSkipResult([new BulkSkipItemResult(0, BulkItemStatus.Success, id)]); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Success(bulkResult)); + + var result = await _tools.BulkSkipHabits(_user, id.ToString()); + + result.Should().Contain("Bulk skip: 1/1 skipped successfully"); + } + + [Fact] + public async Task BulkSkipHabits_Failure_ReturnsError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Failure("Error")); + + var result = await _tools.BulkSkipHabits(_user, Guid.NewGuid().ToString()); + + result.Should().StartWith("Error: "); + } + [Fact] public async Task GetDailySummary_Success_ReturnsSummary() { @@ -571,52 +689,4 @@ public async Task GetAllHabitLogs_Empty_ReturnsNoLogsMessage() result.Should().Contain("No logs found"); } - - [Fact] - public async Task UpdateChecklist_Success_ReturnsUpdatedMessage() - { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); - - var json = "[{\"text\":\"Item 1\",\"isChecked\":true}]"; - var result = await _tools.UpdateChecklist(_user, Guid.NewGuid().ToString(), json); - - result.Should().Contain("Updated checklist"); - result.Should().Contain("1 items"); - } - - [Fact] - public async Task UpdateChecklist_Failure_ReturnsError() - { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Habit not found")); - - var json = "[]"; - var result = await _tools.UpdateChecklist(_user, Guid.NewGuid().ToString(), json); - - result.Should().StartWith("Error: "); - } - - [Fact] - public async Task LinkGoalsToHabit_Success_ReturnsLinkedMessage() - { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success()); - - var goalId = Guid.NewGuid(); - var result = await _tools.LinkGoalsToHabit(_user, Guid.NewGuid().ToString(), goalId.ToString()); - - result.Should().Contain("Linked 1 goals"); - } - - [Fact] - public async Task LinkGoalsToHabit_Failure_ReturnsError() - { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Not found")); - - var result = await _tools.LinkGoalsToHabit(_user, Guid.NewGuid().ToString(), Guid.NewGuid().ToString()); - - result.Should().StartWith("Error: "); - } } diff --git a/tests/Orbit.IntegrationTests/McpHabitExecutorRoutingTests.cs b/tests/Orbit.IntegrationTests/McpHabitExecutorRoutingTests.cs new file mode 100644 index 00000000..91d7f9ba --- /dev/null +++ b/tests/Orbit.IntegrationTests/McpHabitExecutorRoutingTests.cs @@ -0,0 +1,152 @@ +using System.Text.Json; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Orbit.Domain.Interfaces; +using Orbit.Domain.Models; +using Orbit.Infrastructure.Persistence; + +namespace Orbit.IntegrationTests; + +[Collection("Sequential")] +public class McpHabitExecutorRoutingTests : IAsyncLifetime +{ + private readonly IntegrationTestWebApplicationFactory _factory; + private readonly HttpClient _client; + private readonly string _email = $"mcp-executor-{Guid.NewGuid()}@integration.test"; + private const string TestCode = "999999"; + + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + + private Guid _userId; + + public McpHabitExecutorRoutingTests(IntegrationTestWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + IntegrationTestHelpers.RegisterTestAccount(_email, TestCode); + } + + public async Task InitializeAsync() + { + var login = await IntegrationTestHelpers.AuthenticateWithCodeAsync(_client, _email, TestCode, JsonOptions); + _userId = login.UserId; + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + private static JsonElement BuildArguments(object value) => + JsonSerializer.SerializeToElement(value); + + [Fact] + public async Task CreateHabit_ViaMcpSurface_WritesAuditRow() + { + using var scope = _factory.Services.CreateScope(); + var executor = scope.ServiceProvider.GetRequiredService(); + + var response = await executor.ExecuteAsync(new AgentExecuteOperationRequest( + _userId, + "create_habit", + BuildArguments(new + { + title = "MCP routed habit", + due_date = DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd"), + frequency_unit = "Day" + }), + AgentExecutionSurface.Mcp, + AgentAuthMethod.Jwt, + IsReadOnlyCredential: false)); + + response.Operation.Status.Should().Be(AgentOperationStatus.Succeeded); + + var dbContext = scope.ServiceProvider.GetRequiredService(); + var auditRow = await dbContext.AgentAuditLogs + .Where(log => log.UserId == _userId + && log.CapabilityId == "habits.write" + && log.Surface == AgentExecutionSurface.Mcp + && log.OutcomeStatus == AgentOperationStatus.Succeeded) + .OrderByDescending(log => log.CreatedAtUtc) + .FirstOrDefaultAsync(); + + auditRow.Should().NotBeNull(); + auditRow!.SourceName.Should().Be("create_habit"); + } + + [Fact] + public async Task ReadOnlyCredential_IsDeniedAcrossDistinctToolsets() + { + using var scope = _factory.Services.CreateScope(); + var executor = scope.ServiceProvider.GetRequiredService(); + + var seededHabitId = await SeedHabitAsync(executor); + + var grantedScopes = AgentScopes.ClaudeDefaultScopes; + + var habitsWriteDenial = await executor.ExecuteAsync(new AgentExecuteOperationRequest( + _userId, + "create_habit", + BuildArguments(new + { + title = "Denied habit", + due_date = DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd"), + frequency_unit = "Day" + }), + AgentExecutionSurface.Mcp, + AgentAuthMethod.ApiKey, + grantedScopes, + IsReadOnlyCredential: true)); + + var goalsWriteDenial = await executor.ExecuteAsync(new AgentExecuteOperationRequest( + _userId, + "create_goal", + BuildArguments(new { title = "Denied goal" }), + AgentExecutionSurface.Mcp, + AgentAuthMethod.ApiKey, + grantedScopes, + IsReadOnlyCredential: true)); + + var bulkDeleteDenial = await executor.ExecuteAsync(new AgentExecuteOperationRequest( + _userId, + "bulk_delete_habits", + BuildArguments(new { habit_ids = new[] { seededHabitId.ToString() } }), + AgentExecutionSurface.Mcp, + AgentAuthMethod.ApiKey, + grantedScopes, + IsReadOnlyCredential: true)); + + habitsWriteDenial.Operation.Status.Should().Be(AgentOperationStatus.Denied); + habitsWriteDenial.Operation.PolicyReason.Should().Be("read_only_credential"); + + goalsWriteDenial.Operation.Status.Should().Be(AgentOperationStatus.Denied); + goalsWriteDenial.Operation.PolicyReason.Should().Be("read_only_credential"); + + // bulk_delete_habits is Destructive (requires confirmation); proving read-only wins here + // confirms the credential check fires before the confirmation gate. + bulkDeleteDenial.Operation.Status.Should().Be(AgentOperationStatus.Denied); + bulkDeleteDenial.Operation.PolicyReason.Should().Be("read_only_credential"); + } + + private async Task SeedHabitAsync(IAgentOperationExecutor executor) + { + var response = await executor.ExecuteAsync(new AgentExecuteOperationRequest( + _userId, + "create_habit", + BuildArguments(new + { + title = "Seed habit", + due_date = DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd"), + frequency_unit = "Day" + }), + AgentExecutionSurface.Mcp, + AgentAuthMethod.Jwt, + IsReadOnlyCredential: false)); + + response.Operation.Status.Should().Be(AgentOperationStatus.Succeeded); + Guid.TryParse(response.Operation.TargetId, out var habitId).Should().BeTrue(); + return habitId; + } +}