using System.ComponentModel; using System.Reflection; using System.Security.Claims; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol.Server; using Nexus.Api.Data; using Nexus.Api.Integrations; using Nexus.Api.Models; using Nexus.Api.Repositories; using Nexus.Api.Services; using Xunit; namespace Nexus.Api.Tests; public sealed class McpToolsTests { // ──────────────────────────────────────────────────────────────── // Enum Validation // ──────────────────────────────────────────────────────────────── [Fact] public void NexusMcpTaskState_HasExactlyFiveValidStates() { var values = Enum.GetValues(); Assert.Equal(5, values.Length); var names = Enum.GetNames(); Assert.Contains("Backlog", names); Assert.Contains("InProgress", names); Assert.Contains("Blocked", names); Assert.Contains("Done", names); Assert.Contains("Review", names); } [Theory] [InlineData(NexusMcpTaskState.Backlog, "Backlog")] [InlineData(NexusMcpTaskState.InProgress, "In progress")] [InlineData(NexusMcpTaskState.Blocked, "Blocked")] [InlineData(NexusMcpTaskState.Done, "Done")] [InlineData(NexusMcpTaskState.Review, "Review")] public void NexusMcpTaskState_MapsToCorrectStateString(NexusMcpTaskState mcpState, string expectedBridgeState) { // Verify the TaskStateHelper roundtrip works string stateString = mcpState switch { NexusMcpTaskState.Backlog => TaskStateHelper.ToStateString(TaskState.Backlog), NexusMcpTaskState.InProgress => TaskStateHelper.ToStateString(TaskState.InProgress), NexusMcpTaskState.Blocked => TaskStateHelper.ToStateString(TaskState.Blocked), NexusMcpTaskState.Done => TaskStateHelper.ToStateString(TaskState.Done), NexusMcpTaskState.Review => TaskStateHelper.ToStateString(TaskState.Review), _ => throw new ArgumentOutOfRangeException(nameof(mcpState)) }; Assert.Equal(expectedBridgeState, stateString); Assert.True(TaskStateHelper.IsValidState(stateString)); } [Fact] public void NexusMcpTaskState_EnumValuesMatchTaskStateEnum() { // The MCP state enum must cover exactly the canonical task states foreach (var mcpState in Enum.GetValues()) { var taskState = mcpState switch { NexusMcpTaskState.Backlog => TaskState.Backlog, NexusMcpTaskState.InProgress => TaskState.InProgress, NexusMcpTaskState.Blocked => TaskState.Blocked, NexusMcpTaskState.Done => TaskState.Done, NexusMcpTaskState.Review => TaskState.Review, _ => throw new ArgumentOutOfRangeException(nameof(mcpState)) }; Assert.True(Enum.IsDefined(taskState)); } } // ──────────────────────────────────────────────────────────────── // Tool Registration // ──────────────────────────────────────────────────────────────── [Fact] public void AllRequiredTools_AreRegistered() { var toolMethods = typeof(NexusMcpTools) .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) .Where(m => m.GetCustomAttribute() is not null) .Select(m => m.GetCustomAttribute()!.Name!) .OrderBy(n => n) .ToList(); var expected = new[] { "nexus_agent_overview", "nexus_append_activity", "nexus_create_child_task", "nexus_create_task", "nexus_get_activity", "nexus_get_agent_proposal", "nexus_get_board", "nexus_get_children", "nexus_get_task", "nexus_handoff", "nexus_propose_agent", "nexus_update_status" }.OrderBy(n => n).ToList(); Assert.Equal(expected, toolMethods); Assert.Equal(12, toolMethods.Count); } [Fact] public void NexusMcpTools_HasMcpServerToolTypeAttribute() { var attr = typeof(NexusMcpTools).GetCustomAttribute(); Assert.NotNull(attr); } [Fact] public void AllTools_HaveDescriptionAttribute() { var methods = typeof(NexusMcpTools) .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) .Where(m => m.GetCustomAttribute() is not null); foreach (var method in methods) { var desc = method.GetCustomAttribute(); Assert.NotNull(desc); Assert.False(string.IsNullOrWhiteSpace(desc!.Description), $"Tool {method.Name} is missing a description."); } } [Fact] public void NexusMcpTools_AllMethodsAreAsync() { var methods = typeof(NexusMcpTools) .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) .Where(m => m.GetCustomAttribute() is not null); foreach (var method in methods) { Assert.True( method.ReturnType.Name.StartsWith("Task") || method.ReturnType.Name.StartsWith("ValueTask"), $"Tool {method.Name} does not return Task."); } } // ──────────────────────────────────────────────────────────────── // Tool Behavior via Fixture (integration-style) // ──────────────────────────────────────────────────────────────── [Fact] public async Task CreateTask_ReturnsSuccess_ForValidInput() { await using var fixture = await McpToolsFixture.CreateAsync(); fixture.SetCallerAgent("iris"); var result = await fixture.Tools.CreateTask("MCP Test Task", "MCP detail", "High", "iris"); Assert.NotNull(result); Assert.True(result.Ok); Assert.Equal("nexus_create_task", result.Command); Assert.NotNull(result.Data); Assert.Null(result.Error); Assert.Equal("MCP Test Task", result.Data!.Title); } [Fact] public async Task CreateChildTask_ReturnsSuccess_ForValidParent() { await using var fixture = await McpToolsFixture.CreateAsync(); fixture.SetCallerAgent("iris"); var parent = await fixture.Tools.CreateTask("Parent Task", "Parent detail"); Assert.True(parent.Ok && parent.Data is not null); var child = await fixture.Tools.CreateChildTask( parent.Data!.Id, "Child Task", "Child detail", "Normal", "programmer"); Assert.True(child.Ok); Assert.Equal("nexus_create_child_task", child.Command); Assert.NotNull(child.Data); Assert.Equal("Child Task", child.Data!.Title); Assert.Equal(parent.Data.Id, child.Data.ParentTaskId); } [Fact] public async Task CreateChildTask_ReturnsError_ForMissingParent() { await using var fixture = await McpToolsFixture.CreateAsync(); fixture.SetCallerAgent("iris"); var result = await fixture.Tools.CreateChildTask( Guid.NewGuid(), "Orphan Child"); Assert.False(result.Ok); Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task GetBoard_ReturnsGroupedTasks() { await using var fixture = await McpToolsFixture.CreateAsync(); fixture.SetCallerAgent("iris"); // Create test tasks await fixture.Tools.CreateTask("Board Task 1", assignedTo: "iris"); await fixture.Tools.CreateTask("Board Task 2", assignedTo: "programmer"); var board = await fixture.Tools.GetBoard(); Assert.NotNull(board); Assert.NotNull(board.Offen); Assert.NotNull(board.InProgress); Assert.NotNull(board.Review); Assert.NotNull(board.Blocked); Assert.NotNull(board.Done); Assert.True(board.Offen.Count >= 2); } [Fact] public async Task GetTask_ReturnsTask_WhenFound() { await using var fixture = await McpToolsFixture.CreateAsync(); fixture.SetCallerAgent("iris"); var created = await fixture.Tools.CreateTask("GetTask Test"); Assert.True(created.Ok && created.Data is not null); var fetched = await fixture.Tools.GetTask(created.Data.Id); Assert.True(fetched.Ok); Assert.Equal("GetTask Test", fetched.Data!.Title); } [Fact] public async Task GetTask_ReturnsError_WhenNotFound() { await using var fixture = await McpToolsFixture.CreateAsync(); fixture.SetCallerAgent("iris"); var result = await fixture.Tools.GetTask(Guid.NewGuid()); Assert.False(result.Ok); Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task GetChildren_ReturnsChildTasks() { await using var fixture = await McpToolsFixture.CreateAsync(); fixture.SetCallerAgent("iris"); var parent = await fixture.Tools.CreateTask("Parent for Children"); Assert.True(parent.Ok && parent.Data is not null); await fixture.Tools.CreateChildTask(parent.Data.Id, "Child 1"); await fixture.Tools.CreateChildTask(parent.Data.Id, "Child 2"); var children = await fixture.Tools.GetChildren(parent.Data.Id); Assert.NotNull(children); Assert.Equal(2, children.Count); } [Fact] public async Task UpdateStatus_AdvancesState() { await using var fixture = await McpToolsFixture.CreateAsync(); fixture.SetCallerAgent("iris"); var task = await fixture.Tools.CreateTask("Status Test"); Assert.True(task.Ok && task.Data is not null); Assert.Equal("Backlog", task.Data.State); var inProgress = await fixture.Tools.UpdateStatus(task.Data.Id, NexusMcpTaskState.InProgress); Assert.True(inProgress.Ok); Assert.Equal("In progress", inProgress.Data!.State); var done = await fixture.Tools.UpdateStatus(task.Data.Id, NexusMcpTaskState.Done); Assert.True(done.Ok); Assert.Equal("Done", done.Data!.State); } [Fact] public async Task UpdateStatus_Unauthorized_ForSubAgent() { await using var fixture = await McpToolsFixture.CreateAsync(); fixture.SetCallerAgent("programmer"); var task = await fixture.Tools.CreateTask("SubAgent Status Test"); Assert.True(task.Ok && task.Data is not null); // The programmer creates the task fine, but cannot change status var result = await fixture.Tools.UpdateStatus(task.Data.Id, NexusMcpTaskState.InProgress); Assert.False(result.Ok); Assert.Contains("not authorized", result.Error, StringComparison.OrdinalIgnoreCase); } [Theory] [InlineData(NexusMcpTaskState.Backlog)] [InlineData(NexusMcpTaskState.InProgress)] [InlineData(NexusMcpTaskState.Review)] [InlineData(NexusMcpTaskState.Blocked)] [InlineData(NexusMcpTaskState.Done)] public async Task UpdateStatus_AcceptsAllValidStates(NexusMcpTaskState state) { await using var fixture = await McpToolsFixture.CreateAsync(); fixture.SetCallerAgent("iris"); var task = await fixture.Tools.CreateTask($"StateTest-{state}"); Assert.True(task.Ok && task.Data is not null); var result = await fixture.Tools.UpdateStatus(task.Data.Id, state); Assert.True(result.Ok); } [Fact] public async Task AppendActivity_WritesActivityEntry() { await using var fixture = await McpToolsFixture.CreateAsync(); fixture.SetCallerAgent("iris"); var task = await fixture.Tools.CreateTask("Activity Test"); Assert.True(task.Ok && task.Data is not null); var result = await fixture.Tools.AppendActivity(task.Data.Id, "Test checkpoint", "checkpoint"); Assert.True(result.Ok); Assert.NotNull(result.Data); Assert.Equal("Test checkpoint", result.Data!.Message); var activities = await fixture.Tools.GetActivity(task.Data.Id); Assert.NotEmpty(activities); } [Fact] public async Task Handoff_UpdatesExpectedFrom() { await using var fixture = await McpToolsFixture.CreateAsync(); fixture.SetCallerAgent("iris"); var task = await fixture.Tools.CreateTask("Handoff Test"); Assert.True(task.Ok && task.Data is not null); var result = await fixture.Tools.Handoff(task.Data.Id, "programmer", "Please implement"); Assert.True(result.Ok); Assert.Equal("programmer", result.Data!.ExpectedFrom); } [Fact] public async Task GetAgentOverview_ReturnsGroupedWorkflow() { await using var fixture = await McpToolsFixture.CreateAsync(); fixture.SetCallerAgent("iris"); var overview = await fixture.Tools.GetAgentOverview(staleHours: 2); Assert.NotNull(overview); Assert.NotNull(overview.WaitingForBao); Assert.NotNull(overview.WaitingForIris); Assert.NotNull(overview.WaitingForOthers); Assert.NotNull(overview.StaleTasks); } // ──────────────────────────────────────────────────────────────── // Auth Resolution // ──────────────────────────────────────────────────────────────── [Fact] public async Task ResolveCaller_AcceptsValidXAgentId() { await using var fixture = await McpToolsFixture.CreateAsync(); fixture.SetCallerAgent("programmer"); // Simply verify a tool call succeeds with a valid agent header var result = await fixture.Tools.CreateTask("Auth Test via header"); Assert.True(result.Ok); } [Fact] public async Task ResolveCaller_AcceptsJwtClaim() { await using var fixture = await McpToolsFixture.CreateAsync(); fixture.SetCallerUser("iris", "member"); var result = await fixture.Tools.CreateTask("Auth Test via JWT"); Assert.True(result.Ok); } [Fact] public async Task ResolveCaller_AcceptsOwnerRole() { await using var fixture = await McpToolsFixture.CreateAsync(); fixture.SetCallerUser("owner", "owner"); var result = await fixture.Tools.CreateTask("Auth Test via owner JWT"); Assert.True(result.Ok); } [Fact] public async Task ResolveCaller_AcceptsServiceKey() { await using var fixture = await McpToolsFixture.CreateAsync(); fixture.SetCallerServiceKey("test-service-key"); var result = await fixture.Tools.CreateTask("Auth Test via service key"); Assert.True(result.Ok); } [Fact] public async Task ResolveCaller_RejectsUnknownAgentId() { await using var fixture = await McpToolsFixture.CreateAsync(); fixture.SetCallerAgentWithoutAuthentication("hacker"); await Assert.ThrowsAsync( () => fixture.Tools.CreateTask("Should fail")); } [Fact] public async Task ResolveCaller_RejectsMissingAuth() { await using var fixture = await McpToolsFixture.CreateAsync(); // No auth set = should reject await Assert.ThrowsAsync( () => fixture.Tools.CreateTask("Should fail")); } } // ────────────────────────────────────────────────────────────────── // Test Fixture // ────────────────────────────────────────────────────────────────── internal sealed class McpToolsFixture : IAsyncDisposable { private readonly NexusDbContext _db; private McpToolsFixture( NexusDbContext db, NexusMcpTools tools, HttpContextAccessor httpContextAccessor, ITaskBridgeService taskBridgeService, IAgentService agentService) { _db = db; Tools = tools; HttpContextAccessor = httpContextAccessor; TaskBridgeService = taskBridgeService; AgentService = agentService; } public NexusMcpTools Tools { get; } public HttpContextAccessor HttpContextAccessor { get; } public ITaskBridgeService TaskBridgeService { get; } public IAgentService AgentService { get; } public static async Task CreateAsync() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; var db = new NexusDbContext(options); await db.Database.EnsureCreatedAsync(); var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["NexusApiKey"] = "test-service-key" }) .Build(); var agentService = new AgentService(new StubOpenClawControlService()); var liveUpdateService = new LiveUpdateService(); var activityRepository = new ActivityRepository(db, liveUpdateService); var taskRepository = new TaskRepository(db); var notificationService = new NotificationService(db, liveUpdateService); var httpContextAccessor = new HttpContextAccessor(); var staleTaskRecoveryService = new StaleTaskRecoveryService( taskRepository, activityRepository, liveUpdateService); var taskService = new TaskService( taskRepository, activityRepository, notificationService, agentService, httpContextAccessor, liveUpdateService, staleTaskRecoveryService); var taskBridgeService = new TaskBridgeService( taskService, agentService, activityRepository, notificationService, liveUpdateService); var logger = NullLogger.Instance; var tools = new NexusMcpTools( taskBridgeService, agentService, httpContextAccessor, configuration, logger); return new McpToolsFixture(db, tools, httpContextAccessor, taskBridgeService, agentService); } public async ValueTask DisposeAsync() { await _db.DisposeAsync(); } public void SetCallerAgent(string agentId) { var httpContext = new DefaultHttpContext(); httpContext.Request.Headers["X-Agent-Id"] = agentId; httpContext.User = new ClaimsPrincipal(new ClaimsIdentity( [new Claim(ClaimTypes.Role, "Service")], "ApiKey")); HttpContextAccessor.HttpContext = httpContext; } public void SetCallerAgentWithoutAuthentication(string agentId) { var httpContext = new DefaultHttpContext(); httpContext.Request.Headers["X-Agent-Id"] = agentId; httpContext.User = new ClaimsPrincipal(new ClaimsIdentity()); HttpContextAccessor.HttpContext = httpContext; } public void SetCallerUser(string userId, string role) { var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId), new Claim(ClaimTypes.Role, role) }; var httpContext = new DefaultHttpContext(); httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); HttpContextAccessor.HttpContext = httpContext; } public void SetCallerServiceKey(string key) { var claims = new[] { new Claim(ClaimTypes.Role, "Service") }; var httpContext = new DefaultHttpContext(); httpContext.Request.Headers["X-Nexus-Api-Key"] = key; httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "ApiKey")); HttpContextAccessor.HttpContext = httpContext; } }