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 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 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 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> 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> 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> 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. /// /// 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". /// [McpServerTool(Name = "nexus_create_task")] [Description("Create a top-level Nexus task.")] public async Task> 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"); } /// /// 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. /// [McpServerTool(Name = "nexus_create_child_task")] [Description("Create a visible child task under a Nexus parent task for delegation.")] public async Task> 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"); } /// /// Updates a task's lifecycle state. /// The parameter is typed as /// so the MCP SDK rejects unknown /// integer values before the tool is ever invoked. Additionally, /// performs an exhaustive switch with a /// defensive fallback. /// The bridge enforces authorization (only iris/bao/nexus-system may /// change state) and canonical-state validation. /// [McpServerTool(Name = "nexus_update_status")] [Description("Update a Nexus task status. The schema only exposes canonical task states.")] public async Task> 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"); } /// /// Appends an activity entry (comment, status note, or checkpoint) to a task. /// This triggers a live-update broadcast so dashboards stay current. /// [McpServerTool(Name = "nexus_append_activity")] [Description("Append an activity/checkpoint entry to a Nexus task.")] public async Task> 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"); } /// /// 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. /// [McpServerTool(Name = "nexus_handoff")] [Description("Mark a task handoff to another known agent and append handoff activity.")] public async Task> 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 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 ToResponse(TaskBridgeResult 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 ToActivityResponse( TaskBridgeResult 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() }; } /// /// Canonical task states exposed via the MCP tool schema. /// Integer values 0-4 map to the canonical string representations used /// by . The MCP SDK rejects /// out-of-range integers before the tool is invoked. /// public enum NexusMcpTaskState { /// Task is in the backlog (not yet started). Backlog = 0, /// Work is actively in progress. InProgress = 1, /// Work is blocked by an external dependency. Blocked = 2, /// Work is complete. Done = 3, /// Work is ready for review. Review = 4 } /// /// Static helpers for . /// The method provides a testable entry point /// for verifying that update_status rejects invalid state values. /// public static class NexusMcpTaskStateHelper { /// /// Returns true when is one of the five /// canonical values defined in . /// Rejects undefined cast values (e.g. (NexusMcpTaskState)99). /// public static bool IsDefined(NexusMcpTaskState state) => Enum.IsDefined(state); }