using System; using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; namespace D365MetadataBridge.Protocol { /// /// JSON-RPC style request from the Node.js MCP server /// public class BridgeRequest { [JsonPropertyName("id")] public string Id { get; set; } = ""; [JsonPropertyName("method")] public string Method { get; set; } = ""; [JsonPropertyName("params")] public JsonElement? Params { get; set; } /// /// Helper to extract a string parameter from Params /// public string? GetStringParam(string name) { if (Params == null || Params.Value.ValueKind != JsonValueKind.Object) return null; if (Params.Value.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String) return prop.GetString(); return null; } /// /// Helper to extract an integer parameter from Params /// public int? GetIntParam(string name) { if (Params == null || Params.Value.ValueKind != JsonValueKind.Object) return null; if (Params.Value.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.Number) return prop.GetInt32(); return null; } /// /// Helper to extract a boolean parameter from Params. /// Reads through ParamCoercion so this path and HandleBatchModify cannot disagree /// about what a given value means. /// public bool? GetBoolParam(string name) { if (Params == null || Params.Value.ValueKind != JsonValueKind.Object) return null; if (!Params.Value.TryGetProperty(name, out var prop)) return null; return ParamCoercion.ToBool(prop, name); } /// /// Helper to deserialize a complex parameter (array or object) from Params. /// Returns default(T) if parameter is missing or null. /// public T? GetParam(string name) where T : class { if (Params == null || Params.Value.ValueKind != JsonValueKind.Object) return null; if (!Params.Value.TryGetProperty(name, out var prop)) return null; if (prop.ValueKind == JsonValueKind.Null) return null; return JsonSerializer.Deserialize(prop.GetRawText(), JsonOptions.Default); } /// /// Helper to extract a Dictionary<string,string> from a JSON object parameter. /// public Dictionary? GetDictParam(string name) { if (Params == null || Params.Value.ValueKind != JsonValueKind.Object) return null; if (!Params.Value.TryGetProperty(name, out var prop) || prop.ValueKind != JsonValueKind.Object) return null; var dict = new Dictionary(); foreach (var kv in prop.EnumerateObject()) { // GetString() THROWS on any kind other than String/Null — it does not // return null for booleans/numbers/arrays. The old `GetString() ?? …` // therefore crashed the whole request with "requires an element of type // 'String', but the target element has type 'True'/'Array'" whenever a // caller put a non-string value in the map. Coerce by kind instead. switch (kv.Value.ValueKind) { case JsonValueKind.String: dict[kv.Name] = kv.Value.GetString() ?? string.Empty; break; case JsonValueKind.Null: break; // omit null-valued properties default: // bool/number/array/object → raw JSON token ("true", "42", "[…]") dict[kv.Name] = kv.Value.GetRawText(); break; } } return dict; } } /// /// Parameter coercion shared by BOTH dispatch paths. /// /// The single-op path reads its params straight off the request's JsonElement; the /// batch path gets them as Dictionary<string, object>, whose values System.Text.Json /// boxes as JsonElement. JsonElement does not implement IConvertible, so the batch /// path's Convert.ToBoolean(value) threw InvalidCastException ("Object must implement /// IConvertible") for EVERY operation carrying a bool — mandatory, allowDuplicates, /// alternateKey, extendBaseFieldGroup — and HandleBatchModify's per-op catch reported /// that cast as the operation's reason for failing. Booleans are read by JSON kind, in /// one place, so the two paths cannot drift apart again. /// public static class ParamCoercion { /// /// Boolean from a raw (boxed) batch parameter value. /// public static bool? ToBool(object? value, string paramName) { switch (value) { case null: return null; case bool b: return b; case JsonElement el: return ToBool(el, paramName); case string s: return ParseBoolText(s, paramName, s); default: throw new ArgumentException( $"Parameter '{paramName}' must be a boolean — got {value.GetType().Name} '{value}'."); } } /// /// Boolean from a JSON value. Absent/null yields null so the caller's own default /// applies; a value that is present but not readable as a boolean throws rather /// than degrading to false — a silent false writes a field that is not mandatory, /// or an index that allows duplicates, and reports success either way. /// /// The string spellings are accepted because the AxTable XML value is No/Yes and /// callers reach for that form (#27); it is a typo, not a shape, that fails here. /// public static bool? ToBool(JsonElement el, string paramName) { switch (el.ValueKind) { case JsonValueKind.True: return true; case JsonValueKind.False: return false; case JsonValueKind.Null: case JsonValueKind.Undefined: return null; case JsonValueKind.String: return ParseBoolText(el.GetString(), paramName, el.GetRawText()); case JsonValueKind.Number when el.TryGetInt32(out var n) && (n == 0 || n == 1): return n == 1; default: throw new ArgumentException( $"Parameter '{paramName}' must be a boolean — got {el.ValueKind} {el.GetRawText()}. " + "Accepted: true/false, \"true\"/\"false\", \"Yes\"/\"No\", 1/0."); } } private static bool? ParseBoolText(string? text, string paramName, string received) { switch (text?.Trim().ToLowerInvariant()) { case null: case "": return null; case "true": case "yes": case "1": return true; case "false": case "no": case "0": return false; default: throw new ArgumentException( $"Parameter '{paramName}' must be a boolean — got {received}. " + "Accepted: true/false, \"true\"/\"false\", \"Yes\"/\"No\", 1/0."); } } } /// /// JSON-RPC style response sent to the Node.js MCP server /// public class BridgeResponse { [JsonPropertyName("id")] public string Id { get; set; } = ""; [JsonPropertyName("result")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public JsonElement? Result { get; set; } [JsonPropertyName("error")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public BridgeError? Error { get; set; } public static BridgeResponse CreateSuccess(string id, object result) { return new BridgeResponse { Id = id, Result = JsonSerializer.SerializeToElement(result, JsonOptions.Default) }; } public static BridgeResponse CreateError(string id, int code, string message) { return new BridgeResponse { Id = id, Error = new BridgeError { Code = code, Message = message } }; } } /// /// Error object in the response /// public class BridgeError { [JsonPropertyName("code")] public int Code { get; set; } [JsonPropertyName("message")] public string Message { get; set; } = ""; } /// /// Shared JSON serializer options /// public static class JsonOptions { public static readonly JsonSerializerOptions Default = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, WriteIndented = false }; } }