using System.ComponentModel; using System.Diagnostics; 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, IAgentProposalService? agentProposals = null) { // ── 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 an authenticated identity and optional /// X-Agent-Id hint) 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"); } [McpServerTool( Name = "nexus_propose_agent", ReadOnly = false, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true, OutputSchemaType = typeof(AgentProposalToolResult))] [Description( "Propose a new OpenClaw agent for explicit Nexus owner approval. " + "This tool never calls agents.create and never grants its own proposal approval.")] public async Task ProposeAgent( [Description("Human-readable agent name. OpenClaw derives a path-safe id.")] string name, [Description("Stable caller-generated request id used for idempotency.")] string clientRequestId, [Description("Short operational role for the proposed agent.")] string? role = null, [Description("Mission and expected outcome for the proposed agent.")] string? description = null, [Description("Optional configured OpenClaw provider/model id.")] string? model = null, [Description("Optional identity emoji.")] string? emoji = null, CancellationToken ct = default) { var caller = await ResolveCallerAsync(ct); if (agentProposals is null) { return new AgentProposalToolResult( false, "unavailable", "Agent proposal workflow is not registered.", null, "Use the owner-only Nexus setup center."); } try { var result = await agentProposals.CreateAsync( new CreateAgentProposalRequest( name, role, description, model, emoji, ClientRequestId: clientRequestId), caller == "iris" ? "iris" : "mcp", new OpenClawInvocationMetadata( clientRequestId, Activity.Current?.TraceId.ToString() ?? Guid.NewGuid().ToString("N"), caller, Activity.Current?.Id), ct); return new AgentProposalToolResult( result.Ok, result.State, result.Message, result.Proposal, result.Recovery); } catch (AgentProposalValidationException exception) { return new AgentProposalToolResult( false, "invalid", $"{exception.Field}: {exception.Message}", null, "Correct the proposal arguments and submit a new clientRequestId."); } } [McpServerTool( Name = "nexus_get_agent_proposal", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true, OutputSchemaType = typeof(AgentProposalToolResult))] [Description( "Read one Nexus agent proposal and its approval/provisioning state. " + "Proposed markdown content is not returned through MCP.")] public async Task GetAgentProposal( [Description("Nexus agent proposal id.")] Guid proposalId, CancellationToken ct = default) { await ResolveCallerAsync(ct); if (agentProposals is null) { return new AgentProposalToolResult( false, "unavailable", "Agent proposal workflow is not registered.", null, "Use the owner-only Nexus setup center."); } var proposal = await agentProposals.GetByIdAsync( proposalId, includeFileContent: false, ct); return proposal is null ? new AgentProposalToolResult( false, "not_found", "Agent proposal was not found.", null, null) : new AgentProposalToolResult( true, proposal.Status, "Agent proposal loaded.", proposal, proposal.Error?.Recovery); } 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); if (!RequestAuthorizationHelper.HasVerifiedAuthentication(context, configuration)) { logger.LogWarning("MCP: unauthenticated request rejected from {Ip}", context.Connection.RemoteIpAddress); throw new UnauthorizedAccessException("MCP tools require a verified JWT or X-Nexus-Api-Key."); } var agentHeader = await RequestAuthorizationHelper.ResolveAgentHeaderAsync( context, agentService, configuration, ct); if (agentHeader.AgentId is not null) return agentHeader.AgentId; if (agentHeader.HeaderProvided && !agentHeader.IsRecognized) logger.LogWarning( "MCP: ignoring unknown X-Agent-Id from authenticated caller at {Ip}", 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: authenticated request has no permitted identity from {Ip}", context.Connection.RemoteIpAddress); throw new UnauthorizedAccessException("MCP caller is authenticated but has no permitted Nexus identity."); } 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 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(), Operation = result.Outcome == TaskBridgeOutcome.Success ? BuildTaskOperation(command, result.Data) : null }; private 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(), Operation = result.Outcome == TaskBridgeOutcome.Success && result.Data is not null ? OperationResultFactory.FromHttpContext( httpContextAccessor.HttpContext ?? throw new UnauthorizedAccessException("MCP request context is unavailable."), "completed", new EntityRefDto("activity", result.Data.Id.ToString(), result.Data.Type), affectedRefs: result.Data.TaskId is { } taskId ? [new EntityRefDto("task", taskId.ToString())] : []) : null }; private OperationResultDto? BuildTaskOperation(string command, T? data) where T : class { if (command.StartsWith("nexus_get_", StringComparison.Ordinal) || data is not DashboardTaskDto task) { return null; } var affected = new List(); if (task.ProjectId is { } projectId) affected.Add(new EntityRefDto("project", projectId.ToString())); if (task.ParentTaskId is { } parentTaskId) affected.Add(new EntityRefDto("task", parentTaskId.ToString(), "Parent task")); return OperationResultFactory.FromHttpContext( httpContextAccessor.HttpContext ?? throw new UnauthorizedAccessException("MCP request context is unavailable."), "completed", new EntityRefDto("task", task.Id.ToString(), task.Title), affectedRefs: affected); } } /// /// 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); }