Files
nexus/backend/Services/NexusMcpTools.cs
T
developer 3bc7622977
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) Successful in 27s
P1c: 5 mutierende MCP-Tools als ITaskBridgeService-Fassade mit State-Enum-Validation
- nexus_create_task: Fassade → bridge.CreateTaskAsync()
- nexus_create_child_task: Fassade → bridge.CreateChildTaskAsync()
- nexus_update_status: Enum-validiert via NexusMcpTaskState (0-4)
  MCP SDK lehnt ungültige Integer-Werte vor Tool-Aufruf ab
  ToStateString() hat exhaustiven Switch + InvalidEnumArgumentException-Fallback
  Bridge erzwingt CanChangeState-Autorisierung (nur iris/bao/nexus-system)
- nexus_append_activity: Fassade → bridge.AppendActivityAsync()
- nexus_handoff: Fassade → bridge.HandoffAsync()

Neue NexusMcpTaskStateHelper.IsDefined() für testbare State-Validierung.
Alle 5 Tools via [McpServerTool] auf McpServerToolType-Klasse registriert.
Keine Businesslogik-Duplizierung — volle Delegation an ITaskBridgeService.
Build: 0w 0e.
2026-07-14 12:09:32 +02:00

297 lines
13 KiB
C#

using System.ComponentModel;
using System.Security.Claims;
using ModelContextProtocol.Server;
using Nexus.Api.Controllers;
using Nexus.Api.Data;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
[McpServerToolType]
public sealed class NexusMcpTools(
ITaskBridgeService bridge,
IAgentService agentService,
IHttpContextAccessor httpContextAccessor,
IConfiguration configuration,
ILogger<NexusMcpTools> logger)
{
// ── P1b: Read-only MCP Tools (TaskBridgeService facade) ──
[McpServerTool(Name = "nexus_get_board")]
[Description("Get the full Nexus task board grouped by canonical states.")]
public async Task<BoardResponse> GetBoard(CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
return await bridge.GetBoardAsync(ct);
}
[McpServerTool(Name = "nexus_agent_overview")]
[Description("Get agent workflow overview, including waiting and stale task groups.")]
public async Task<AgentWorkflowOverview> GetAgentOverview(
[Description("Stale threshold in hours. Defaults to 2.")]
int staleHours = 2,
CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
return await bridge.GetAgentOverviewAsync(TimeSpan.FromHours(Math.Max(1, staleHours)), ct);
}
[McpServerTool(Name = "nexus_get_task")]
[Description("Get one Nexus task by ID.")]
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> GetTask(Guid taskId, CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
return ToResponse(await bridge.GetTaskAsync(taskId, ct), "nexus_get_task");
}
[McpServerTool(Name = "nexus_get_children")]
[Description("Get child tasks for a Nexus parent task.")]
public async Task<IReadOnlyList<DashboardTaskDto>> GetChildren(Guid parentTaskId, CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
return await bridge.GetChildTasksAsync(parentTaskId, ct);
}
[McpServerTool(Name = "nexus_get_activity")]
[Description("Get activity entries for a Nexus task.")]
public async Task<IReadOnlyList<ActivityEntryDto>> GetActivity(Guid taskId, CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
var activity = await bridge.GetTaskActivityAsync(taskId, ct);
return activity.Select(entry => new ActivityEntryDto(entry.Id, entry.Type, entry.Message, entry.CreatedAt)).ToList();
}
// ── P1c: Mutating MCP Tools (TaskBridgeService facade) ──
//
// Each tool delegates directly to ITaskBridgeService without introducing
// new business logic. Authorization, validation, and side-effects
// (notifications, live-update broadcasts) are handled by the bridge.
/// <summary>
/// Creates a top-level task on the Nexus board.
/// The caller (derived from X-Agent-Id or JWT) is set as the default
/// assignee and source. Priority defaults to "Normal".
/// </summary>
[McpServerTool(Name = "nexus_create_task")]
[Description("Create a top-level Nexus task.")]
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> CreateTask(
[Description("Task title (required).")] string title,
[Description("Optional long-form description.")] string? detail = null,
[Description("Priority label. Defaults to 'Normal'.")] string? priority = "Normal",
[Description("Agent ID to assign the task to. Defaults to the caller.")] string? assignedTo = null,
CancellationToken ct = default)
{
var caller = await ResolveCallerAsync(ct);
var result = await bridge.CreateTaskAsync(
title: title,
detail: detail,
source: ResolveSource(caller),
priority: priority,
assignedTo: assignedTo ?? caller,
ct: ct);
return ToResponse(result, "nexus_create_task");
}
/// <summary>
/// Creates a visible child task under a Nexus parent for delegation.
/// If the parent is in Backlog, it is automatically moved to In progress
/// to signal that coordination has started.
/// </summary>
[McpServerTool(Name = "nexus_create_child_task")]
[Description("Create a visible child task under a Nexus parent task for delegation.")]
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> CreateChildTask(
[Description("ID of the parent task.")] Guid parentTaskId,
[Description("Child task title (required).")] string title,
[Description("Optional long-form description.")] string? detail = null,
[Description("Priority label. Defaults to 'Normal'.")] string? priority = "Normal",
[Description("Agent ID to assign. Defaults to the expected-from agent.")] string? assignedTo = null,
[Description("Agent who is expected to deliver this work.")] string? expectedFrom = null,
[Description("If true, the child starts in 'In progress' instead of Backlog.")] bool startsInProgress = false,
CancellationToken ct = default)
{
var caller = await ResolveCallerAsync(ct);
var result = await bridge.CreateChildTaskAsync(
parentTaskId: parentTaskId,
title: title,
detail: detail,
source: ResolveSource(caller),
priority: priority,
assignedTo: assignedTo,
expectedFrom: expectedFrom ?? assignedTo,
startsInProgress: startsInProgress,
ct: ct);
return ToResponse(result, "nexus_create_child_task");
}
/// <summary>
/// Updates a task's lifecycle state.
/// The <paramref name="state"/> parameter is typed as
/// <see cref="NexusMcpTaskState"/> so the MCP SDK rejects unknown
/// integer values before the tool is ever invoked. Additionally,
/// <see cref="ToStateString"/> performs an exhaustive switch with a
/// defensive <see cref="InvalidEnumArgumentException"/> fallback.
/// The bridge enforces authorization (only iris/bao/nexus-system may
/// change state) and canonical-state validation.
/// </summary>
[McpServerTool(Name = "nexus_update_status")]
[Description("Update a Nexus task status. The schema only exposes canonical task states.")]
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> UpdateStatus(
[Description("ID of the task to update.")] Guid taskId,
[Description("New state: Backlog (0), InProgress (1), Blocked (2), Done (3), Review (4).")] NexusMcpTaskState state,
CancellationToken ct = default)
{
var caller = await ResolveCallerAsync(ct);
var result = await bridge.UpdateStatusAsync(taskId, ToStateString(state), caller, ct);
return ToResponse(result, "nexus_update_status");
}
/// <summary>
/// Appends an activity entry (comment, status note, or checkpoint) to a task.
/// This triggers a live-update broadcast so dashboards stay current.
/// </summary>
[McpServerTool(Name = "nexus_append_activity")]
[Description("Append an activity/checkpoint entry to a Nexus task.")]
public async Task<TaskBridgeCommandResponse<ActivityEntryDto>> AppendActivity(
[Description("ID of the task to annotate.")] Guid taskId,
[Description("Activity message text (required).")] string message,
[Description("Activity type: 'comment', 'status', 'agent-note', 'handoff'. Defaults to 'comment'.")] string? type = "comment",
CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
var result = await bridge.AppendActivityAsync(taskId, message, type, ct);
return ToActivityResponse(result, "nexus_append_activity");
}
/// <summary>
/// Marks a task handoff to another agent.
/// Updates ExpectedFrom (and AssignedTo for standalone tasks), appends
/// a handoff activity entry, and sends a notification to the target agent.
/// </summary>
[McpServerTool(Name = "nexus_handoff")]
[Description("Mark a task handoff to another known agent and append handoff activity.")]
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> Handoff(
[Description("ID of the task to hand off.")] Guid taskId,
[Description("Target agent ID (must be a known agent).")] string targetAgent,
[Description("Optional handoff note.")] string? note = null,
CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
var result = await bridge.HandoffAsync(taskId, targetAgent, note, ct);
return ToResponse(result, "nexus_handoff");
}
private async Task<string> ResolveCallerAsync(CancellationToken ct)
{
var context = httpContextAccessor.HttpContext
?? throw new UnauthorizedAccessException("MCP request context is not available.");
var allowedAgentIds = await agentService.GetAllowedAgentIdsAsync(ct);
var allowedActorIds = AgentIdentityCatalog.BuildAllowedActorIds(allowedAgentIds);
var agentHeader = context.Request.Headers["X-Agent-Id"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(agentHeader))
{
var normalizedHeader = agentHeader.Trim().ToLowerInvariant();
if (allowedActorIds.Contains(normalizedHeader))
return normalizedHeader;
logger.LogWarning("MCP: ignoring unknown X-Agent-Id '{AgentId}' from {Ip}",
normalizedHeader,
context.Connection.RemoteIpAddress);
}
if (context.User.Identity?.IsAuthenticated == true)
{
var normalizedClaim = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim().ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(normalizedClaim) && allowedActorIds.Contains(normalizedClaim))
return normalizedClaim;
if (context.User.IsInRole("owner") || context.User.IsInRole("admin"))
return "bao";
}
if (RequestAuthorizationHelper.IsAuthenticatedService(context, configuration) &&
allowedActorIds.Contains("nexus-system"))
return "nexus-system";
logger.LogWarning("MCP: unauthenticated request rejected from {Ip}", context.Connection.RemoteIpAddress);
throw new UnauthorizedAccessException("MCP tools require X-Nexus-Api-Key or a recognized X-Agent-Id.");
}
private static string ResolveSource(string agentId) => agentId switch
{
"bao" or "nexus-system" => "bao",
_ => agentId
};
private static string ToStateString(NexusMcpTaskState state) => state 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 InvalidEnumArgumentException(nameof(state), (int)state, typeof(NexusMcpTaskState))
};
private static TaskBridgeCommandResponse<T> ToResponse<T>(TaskBridgeResult<T> result, string command) where T : class
=> new()
{
Ok = result.Outcome == TaskBridgeOutcome.Success,
Command = command,
Data = result.Outcome == TaskBridgeOutcome.Success ? result.Data : null,
Error = result.Outcome == TaskBridgeOutcome.Success ? null : result.Error ?? result.Outcome.ToString()
};
private static TaskBridgeCommandResponse<ActivityEntryDto> ToActivityResponse(
TaskBridgeResult<ActivityEvent> result,
string command)
=> new()
{
Ok = result.Outcome == TaskBridgeOutcome.Success,
Command = command,
Data = result.Data is null
? null
: new ActivityEntryDto(result.Data.Id, result.Data.Type, result.Data.Message, result.Data.CreatedAt),
Error = result.Outcome == TaskBridgeOutcome.Success ? null : result.Error ?? result.Outcome.ToString()
};
}
/// <summary>
/// Canonical task states exposed via the MCP tool schema.
/// Integer values 0-4 map to the canonical string representations used
/// by <see cref="TaskStateHelper.AllStates"/>. The MCP SDK rejects
/// out-of-range integers before the tool is invoked.
/// </summary>
public enum NexusMcpTaskState
{
/// <summary>Task is in the backlog (not yet started).</summary>
Backlog = 0,
/// <summary>Work is actively in progress.</summary>
InProgress = 1,
/// <summary>Work is blocked by an external dependency.</summary>
Blocked = 2,
/// <summary>Work is complete.</summary>
Done = 3,
/// <summary>Work is ready for review.</summary>
Review = 4
}
/// <summary>
/// Static helpers for <see cref="NexusMcpTaskState"/>.
/// The <see cref="IsDefined"/> method provides a testable entry point
/// for verifying that <c>update_status</c> rejects invalid state values.
/// </summary>
public static class NexusMcpTaskStateHelper
{
/// <summary>
/// Returns true when <paramref name="state"/> is one of the five
/// canonical values defined in <see cref="NexusMcpTaskState"/>.
/// Rejects undefined cast values (e.g. <c>(NexusMcpTaskState)99</c>).
/// </summary>
public static bool IsDefined(NexusMcpTaskState state) =>
Enum.IsDefined(state);
}