Editor/Contracts/McpContracts.cs
#nullable enable
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Editor.Mcp
{
public enum ToolStatus
{
Ready,
Degraded,
Unavailable,
Failed
}
public sealed class ToolDiagnostic
{
public string Code { get; init; } = "unknown";
public string Message { get; init; } = "";
public string? Remediation { get; init; }
public string? Detail { get; init; }
}
public sealed class ToolOutcome<T>
{
public bool Success { get; init; }
public ToolStatus Status { get; init; }
public T? Value { get; init; }
public List<ToolDiagnostic> Diagnostics { get; init; } = new();
public List<string> Warnings { get; init; } = new();
public long DurationMs { get; init; }
public string CorrelationId { get; init; } = Guid.NewGuid().ToString("N");
public static ToolOutcome<T> Ready(T value, params string[] warnings) => new()
{
Success = true,
Status = ToolStatus.Ready,
Value = value,
Warnings = new List<string>(warnings)
};
public static ToolOutcome<T> Degraded(T? value, string code, string message, string? remediation = null) => new()
{
Success = value is not null,
Status = ToolStatus.Degraded,
Value = value,
Diagnostics = new List<ToolDiagnostic> { new() { Code = code, Message = message, Remediation = remediation } }
};
public static ToolOutcome<T> Failure(T? value, ToolStatus status, string code, string message, Exception? exception = null, string? remediation = null) => new()
{
Success = false,
Status = status,
Value = value,
// Never serialize exception messages or stacks: they commonly contain local paths,
// provider URLs, request data, or other details that are unsafe for remote MCP clients.
Diagnostics = new List<ToolDiagnostic> { new() { Code = code, Message = message, Remediation = remediation, Detail = exception is null ? null : "diagnostic detail is available in the local editor log" } }
};
}
public static class McpJson
{
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
public static string Write<T>(ToolOutcome<T> outcome) => JsonSerializer.Serialize(outcome, Options);
public static string Write<T>(T value) => JsonSerializer.Serialize(value, Options);
}
public sealed record CapabilityReport(
string Tool,
string Access,
string RequiredEditorState,
string RequiredDependency,
ToolStatus Status,
string Message,
DateTimeOffset CheckedAt);
public static class ToolGuard
{
public static bool IsValidName(string value) => !string.IsNullOrWhiteSpace(value) && value.Trim().Length <= 128;
public static string Normalize(string value) => (value ?? "").Trim().Replace('\\', '/');
public static string ResolvePath(string value)
{
var normalized = Normalize(value); if (System.IO.Path.IsPathRooted(normalized)) return normalized;
var root = Sandbox.Project.Current?.GetRootPath() ?? Environment.CurrentDirectory; return System.IO.Path.GetFullPath(System.IO.Path.Combine(root, normalized));
}
public static string ResolveAssetPath(string value)
{
var normalized = Normalize(value);
if (System.IO.Path.IsPathRooted(normalized)) return System.IO.Path.GetFullPath(normalized);
var root = Sandbox.Project.Current?.GetRootPath() ?? Environment.CurrentDirectory;
var direct = System.IO.Path.GetFullPath(System.IO.Path.Combine(root, normalized));
if (System.IO.File.Exists(direct) || System.IO.Directory.Exists(direct)) return direct;
return System.IO.Path.GetFullPath(System.IO.Path.Combine(root, "Assets", normalized));
}
public static string Clip(string value, int max = 4000) => string.IsNullOrEmpty(value) || value.Length <= max ? value : value[..max] + "…";
}
}