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] + "…";
	}
}