Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ public static WebApplicationBuilder AddOrbitAiServices(this WebApplicationBuilde
builder.Services.AddScoped<IAgentAuditService, AgentAuditService>();
builder.Services.AddScoped<IAgentTargetOwnershipService, AgentTargetOwnershipService>();
builder.Services.AddScoped<IAgentOperationExecutor, AgentOperationExecutor>();
builder.Services.AddScoped<Orbit.Api.Mcp.McpExecutorBridge>();

// AI Tool Registration
builder.Services.AddScoped<IAiTool, LogHabitTool>();
Expand Down Expand Up @@ -196,6 +197,12 @@ public static WebApplicationBuilder AddOrbitAiServices(this WebApplicationBuilde
builder.Services.AddScoped<IAiTool, ManageApiKeysTool>();
builder.Services.AddScoped<IAiTool, SendSupportRequestTool>();
builder.Services.AddScoped<IAiTool, ManageAccountTool>();
builder.Services.AddScoped<IAiTool, UpdateChecklistTool>();
builder.Services.AddScoped<IAiTool, ReorderHabitsTool>();
builder.Services.AddScoped<IAiTool, MoveHabitParentTool>();
builder.Services.AddScoped<IAiTool, LinkGoalsToHabitTool>();
builder.Services.AddScoped<IAiTool, BulkCreateHabitsTool>();
builder.Services.AddScoped<IAiTool, BulkDeleteHabitsTool>();
builder.Services.AddScoped<AiToolRegistry>();
builder.Services.AddSingleton<ISystemPromptBuilder, SystemPromptBuilder>();

Expand Down
81 changes: 81 additions & 0 deletions src/Orbit.Api/Mcp/McpExecutorBridge.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using System.Security.Claims;
using System.Text.Json;
using System.Text.Json.Serialization;
using Orbit.Api.Extensions;
using Orbit.Domain.Interfaces;
using Orbit.Domain.Models;

namespace Orbit.Api.Mcp;

/// <summary>
/// Routes MCP tool mutations through the shared <see cref="IAgentOperationExecutor"/> so they
/// pass the same policy evaluation and <c>AgentAuditLogs</c> trail as every other agent surface.
/// Serializes a caller-supplied snake_case argument object into the <see cref="JsonElement"/> the
/// backing <c>IAiTool</c> expects, builds the request with <see cref="AgentExecutionSurface.Mcp"/>
/// and the four claim-derived credential fields, and maps the executor outcome back to the legacy
/// MCP string contract: callers format their own success message; denials/failures become
/// <c>"Error: …"</c>; pending-confirmation outcomes return a deterministic confirmation prompt.
/// </summary>
public class McpExecutorBridge(IAgentOperationExecutor operationExecutor)
{
private static readonly JsonSerializerOptions ArgumentSerializerOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};

public async Task<McpExecutorResult> ExecuteAsync(
ClaimsPrincipal user,
string operationId,
object arguments,
string? confirmationToken,
CancellationToken cancellationToken)
{
var argumentsElement = JsonSerializer.SerializeToElement(arguments, ArgumentSerializerOptions);

var response = await operationExecutor.ExecuteAsync(new AgentExecuteOperationRequest(
user.GetUserId(),
operationId,
argumentsElement,
AgentExecutionSurface.Mcp,
user.GetAgentAuthMethod(),
user.GetGrantedAgentScopes(),
user.IsReadOnlyCredential(),
confirmationToken), cancellationToken);

return Map(response.Operation);
}

private static McpExecutorResult Map(AgentOperationResult operation) => operation.Status switch
{
AgentOperationStatus.Succeeded => new McpExecutorResult(
operation.Status, operation.TargetId, operation.TargetName, operation.Payload, Message: string.Empty),
AgentOperationStatus.PendingConfirmation => new McpExecutorResult(
operation.Status, operation.TargetId, operation.TargetName, operation.Payload,
Message: BuildPendingMessage(operation)),
_ => new McpExecutorResult(
operation.Status, operation.TargetId, operation.TargetName, operation.Payload,
Message: $"Error: {operation.PolicyReason ?? operation.Summary ?? "operation failed"}")
};

private static string BuildPendingMessage(AgentOperationResult operation)
{
var pendingId = operation.PendingOperationId?.ToString() ?? "unknown";
return $"Confirmation required before this action runs. Confirm pending operation {pendingId} " +
"via confirm_agent_operation_v2, then retry with the returned confirmation token.";
}
}

/// <summary>
/// Outcome of an MCP mutation routed through <see cref="McpExecutorBridge"/>. On success the caller
/// formats its own message from <see cref="TargetId"/>/<see cref="TargetName"/>/<see cref="Payload"/>;
/// otherwise <see cref="Message"/> carries the ready-to-return error or confirmation string.
/// </summary>
public record McpExecutorResult(
AgentOperationStatus Status,
string? TargetId,
string? TargetName,
object? Payload,
string Message)
{
public bool Succeeded => Status == AgentOperationStatus.Succeeded;
}
Loading
Loading