462 lines
20 KiB
C#
462 lines
20 KiB
C#
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<NexusMcpTools> 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<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 an authenticated identity and optional
|
|
/// X-Agent-Id hint) 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");
|
|
}
|
|
|
|
[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<AgentProposalToolResult> 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<AgentProposalToolResult> 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<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);
|
|
|
|
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<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(),
|
|
Operation = result.Outcome == TaskBridgeOutcome.Success
|
|
? BuildTaskOperation(command, result.Data)
|
|
: null
|
|
};
|
|
|
|
private TaskBridgeCommandResponse<ActivityEntryDto> ToActivityResponse(
|
|
TaskBridgeResult<Nexus.Api.Data.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(),
|
|
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<T>(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<EntityRefDto>();
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <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);
|
|
}
|