From 77b9587fa6372a688436db115dd17da7a311303c Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 13 Jul 2026 08:10:36 +0200 Subject: [PATCH] test: add comprehensive MCP tool and server configuration tests - McpToolsTests: 33 tests covering tool registration, enum validation, state transitions, auth resolution (JWT, API key, X-Agent-Id), create/read/update/activity/handoff workflows via McpToolsFixture - McpServerConfigurationTests: 5 tests verifying MCP server DI registration, tool type attribute, MapMcp endpoint wiring, service extensions, and NuGet package reference --- backend-tests/McpServerConfigurationTests.cs | 85 +++ backend-tests/McpToolsTests.cs | 582 +++++++++++++++++++ 2 files changed, 667 insertions(+) create mode 100644 backend-tests/McpServerConfigurationTests.cs create mode 100644 backend-tests/McpToolsTests.cs diff --git a/backend-tests/McpServerConfigurationTests.cs b/backend-tests/McpServerConfigurationTests.cs new file mode 100644 index 0000000..58d4a51 --- /dev/null +++ b/backend-tests/McpServerConfigurationTests.cs @@ -0,0 +1,85 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Server; +using Nexus.Api.Services; +using Xunit; + +namespace Nexus.Api.Tests; + +/// +/// Verifies that the MCP server configuration is correct: +/// all tools are registered, the streamable-http transport is configured +/// via the service extensions, and MapMcp is called in Program.cs. +/// +public sealed class McpServerConfigurationTests +{ + [Fact] + public void McpServer_CanBeRegistered_WithoutError() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOptions(); + services.AddHttpContextAccessor(); + + // Simulate what AddNexusApplicationServices does + services.AddMcpServer() + .WithHttpTransport(options => options.Stateless = true) + .WithTools(); + + // Build the container — this should not throw + var provider = services.BuildServiceProvider(); + + // Verify the ToolType is properly decorated + var attr = typeof(NexusMcpTools).GetCustomAttributes( + typeof(McpServerToolTypeAttribute), inherit: false); + Assert.Single(attr); + } + + [Fact] + public void McpServer_ToolTypeAttribute_IsPresent() + { + var attr = typeof(NexusMcpTools).GetCustomAttributes( + typeof(McpServerToolTypeAttribute), inherit: false); + Assert.Single(attr); + } + + [Fact] + public void McpEndpoint_MapMcp_IsCalledInProgram() + { + // Source path relative to the test output directory + var programPath = ResolveSourcePath("backend", "Program.cs"); + Assert.True(File.Exists(programPath), $"Program.cs not found at {programPath}"); + var source = File.ReadAllText(programPath); + Assert.Contains("MapMcp", source, StringComparison.Ordinal); + } + + [Fact] + public void McpServerRegistration_IsCalledInServiceExtensions() + { + var extPath = ResolveSourcePath("backend", "Extensions", "ServiceCollectionExtensions.cs"); + Assert.True(File.Exists(extPath), $"ServiceCollectionExtensions.cs not found at {extPath}"); + var source = File.ReadAllText(extPath); + Assert.Contains("AddMcpServer", source, StringComparison.Ordinal); + Assert.Contains("WithHttpTransport", source, StringComparison.Ordinal); + Assert.Contains("Stateless", source, StringComparison.Ordinal); + Assert.Contains("WithTools", source, StringComparison.Ordinal); + } + + [Fact] + public void NuGetPackage_ModelContextProtocol_AspNetCore_IsReferenced() + { + var csprojPath = ResolveSourcePath("backend", "Nexus.Api.csproj"); + Assert.True(File.Exists(csprojPath), $"Nexus.Api.csproj not found at {csprojPath}"); + var source = File.ReadAllText(csprojPath); + Assert.Contains("ModelContextProtocol.AspNetCore", source, StringComparison.Ordinal); + } + + private static string ResolveSourcePath(params string[] segments) + { + // Navigate from test output directory to the repo root + // Test DLL is at: backend-tests/bin/Debug/net10.0/Nexus.Api.Tests.dll + // We go up 4 levels (net10.0 → Debug → bin → backend-tests) to reach repo root + var baseDir = AppContext.BaseDirectory; + var repoRoot = Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..")); + return Path.Combine(new[] { repoRoot }.Concat(segments).ToArray()); + } +} diff --git a/backend-tests/McpToolsTests.cs b/backend-tests/McpToolsTests.cs new file mode 100644 index 0000000..5b4dd6f --- /dev/null +++ b/backend-tests/McpToolsTests.cs @@ -0,0 +1,582 @@ +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_board", + "nexus_get_children", + "nexus_get_task", + "nexus_handoff", + "nexus_update_status" + }.OrderBy(n => n).ToList(); + + Assert.Equal(expected, toolMethods); + Assert.Equal(10, 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.SetCallerAgent("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 configPath = CreateAgentConfigFile(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["AgentConfigPath"] = configPath, + ["NexusApiKey"] = "test-service-key" + }) + .Build(); + + var agentService = new AgentService(configuration, new FakeRuntime()); + 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()); + 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; + } + + private static string CreateAgentConfigFile() + { + var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json"); + File.WriteAllText(path, + """ + { + "agents": { + "defaults": { + "workspace": "/workspace/default", + "model": { + "primary": "deepseek/deepseek-v4-flash" + } + }, + "list": [ + { "id": "iris", "name": "iris", "model": { "primary": "openai/gpt-5.5" } }, + { "id": "product-owner", "name": "product-owner" }, + { "id": "programmer", "name": "programmer" }, + { "id": "programmer-fast", "name": "programmer-fast" }, + { "id": "reviewer", "name": "reviewer" }, + { "id": "architekt", "name": "architekt" }, + { "id": "executor", "name": "executor" }, + { "id": "researcher", "name": "researcher" } + ] + } + } + """); + + return path; + } +}