233 lines
9.2 KiB
C#
233 lines
9.2 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)
|
|
{
|
|
[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();
|
|
}
|
|
|
|
[McpServerTool(Name = "nexus_create_task")]
|
|
[Description("Create a top-level Nexus task.")]
|
|
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> CreateTask(
|
|
string title,
|
|
string? detail = null,
|
|
string? priority = "Normal",
|
|
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");
|
|
}
|
|
|
|
[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(
|
|
Guid parentTaskId,
|
|
string title,
|
|
string? detail = null,
|
|
string? priority = "Normal",
|
|
string? assignedTo = null,
|
|
string? expectedFrom = null,
|
|
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");
|
|
}
|
|
|
|
[McpServerTool(Name = "nexus_update_status")]
|
|
[Description("Update a Nexus task status. The schema only exposes canonical task states.")]
|
|
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> UpdateStatus(
|
|
Guid taskId,
|
|
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");
|
|
}
|
|
|
|
[McpServerTool(Name = "nexus_append_activity")]
|
|
[Description("Append an activity/checkpoint entry to a Nexus task.")]
|
|
public async Task<TaskBridgeCommandResponse<ActivityEntryDto>> AppendActivity(
|
|
Guid taskId,
|
|
string message,
|
|
string? type = "comment",
|
|
CancellationToken ct = default)
|
|
{
|
|
await ResolveCallerAsync(ct);
|
|
var result = await bridge.AppendActivityAsync(taskId, message, type, ct);
|
|
return ToActivityResponse(result, "nexus_append_activity");
|
|
}
|
|
|
|
[McpServerTool(Name = "nexus_handoff")]
|
|
[Description("Mark a task handoff to another known agent and append handoff activity.")]
|
|
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> Handoff(
|
|
Guid taskId,
|
|
string targetAgent,
|
|
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()
|
|
};
|
|
}
|
|
|
|
public enum NexusMcpTaskState
|
|
{
|
|
Backlog,
|
|
InProgress,
|
|
Blocked,
|
|
Done,
|
|
Review
|
|
}
|