test: add comprehensive MCP tool and server configuration tests
CI - Build & Test / Backend (.NET) (push) Successful in 32s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 2s
CI - Build & Test / Deploy Nexus (push) Failing after 10s

- 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
This commit is contained in:
2026-07-13 08:10:36 +02:00
parent 17dc84082c
commit 77b9587fa6
2 changed files with 667 additions and 0 deletions
@@ -0,0 +1,85 @@
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Server;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
/// <summary>
/// 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.
/// </summary>
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<NexusMcpTools>();
// 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<NexusMcpTools>", 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());
}
}
+582
View File
@@ -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<NexusMcpTaskState>();
Assert.Equal(5, values.Length);
var names = Enum.GetNames<NexusMcpTaskState>();
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<NexusMcpTaskState>())
{
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<McpServerToolAttribute>() is not null)
.Select(m => m.GetCustomAttribute<McpServerToolAttribute>()!.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<McpServerToolTypeAttribute>();
Assert.NotNull(attr);
}
[Fact]
public void AllTools_HaveDescriptionAttribute()
{
var methods = typeof(NexusMcpTools)
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Where(m => m.GetCustomAttribute<McpServerToolAttribute>() is not null);
foreach (var method in methods)
{
var desc = method.GetCustomAttribute<DescriptionAttribute>();
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<McpServerToolAttribute>() 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<UnauthorizedAccessException>(
() => 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<UnauthorizedAccessException>(
() => 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<McpToolsFixture> CreateAsync()
{
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var db = new NexusDbContext(options);
await db.Database.EnsureCreatedAsync();
var configPath = CreateAgentConfigFile();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["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<NexusMcpTools>.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;
}
}