feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Claims;
|
||||
using ModelContextProtocol.Server;
|
||||
using Nexus.Api.Controllers;
|
||||
@@ -13,7 +14,8 @@ public sealed class NexusMcpTools(
|
||||
IAgentService agentService,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IConfiguration configuration,
|
||||
ILogger<NexusMcpTools> logger)
|
||||
ILogger<NexusMcpTools> logger,
|
||||
IAgentProposalService? agentProposals = null)
|
||||
{
|
||||
// ── P1b: Read-only MCP Tools (TaskBridgeService facade) ──
|
||||
|
||||
@@ -69,7 +71,8 @@ public sealed class NexusMcpTools(
|
||||
|
||||
/// <summary>
|
||||
/// Creates a top-level task on the Nexus board.
|
||||
/// The caller (derived from X-Agent-Id or JWT) is set as the default
|
||||
/// 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")]
|
||||
@@ -182,6 +185,125 @@ public sealed class NexusMcpTools(
|
||||
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
|
||||
@@ -190,18 +312,25 @@ public sealed class NexusMcpTools(
|
||||
var allowedAgentIds = await agentService.GetAllowedAgentIdsAsync(ct);
|
||||
var allowedActorIds = AgentIdentityCatalog.BuildAllowedActorIds(allowedAgentIds);
|
||||
|
||||
var agentHeader = context.Request.Headers["X-Agent-Id"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(agentHeader))
|
||||
if (!RequestAuthorizationHelper.HasVerifiedAuthentication(context, configuration))
|
||||
{
|
||||
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);
|
||||
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();
|
||||
@@ -216,8 +345,8 @@ public sealed class NexusMcpTools(
|
||||
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.");
|
||||
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
|
||||
@@ -236,17 +365,20 @@ public sealed class NexusMcpTools(
|
||||
_ => throw new InvalidEnumArgumentException(nameof(state), (int)state, typeof(NexusMcpTaskState))
|
||||
};
|
||||
|
||||
private static TaskBridgeCommandResponse<T> ToResponse<T>(TaskBridgeResult<T> result, string command) where T : class
|
||||
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()
|
||||
Error = result.Outcome == TaskBridgeOutcome.Success ? null : result.Error ?? result.Outcome.ToString(),
|
||||
Operation = result.Outcome == TaskBridgeOutcome.Success
|
||||
? BuildTaskOperation(command, result.Data)
|
||||
: null
|
||||
};
|
||||
|
||||
private static TaskBridgeCommandResponse<ActivityEntryDto> ToActivityResponse(
|
||||
TaskBridgeResult<ActivityEvent> result,
|
||||
private TaskBridgeCommandResponse<ActivityEntryDto> ToActivityResponse(
|
||||
TaskBridgeResult<Nexus.Api.Data.ActivityEvent> result,
|
||||
string command)
|
||||
=> new()
|
||||
{
|
||||
@@ -255,8 +387,41 @@ public sealed class NexusMcpTools(
|
||||
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()
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user