using System.Reflection; using ClaudeDo.Worker.External; using Microsoft.Extensions.AI; using ModelContextProtocol.Server; namespace ClaudeDo.Worker.Tests.External; /// /// MCP clients routinely omit optional arguments. The generated tool schema only marks a /// parameter optional when the C# method declares a default value — nullability alone is not /// enough (verified against ModelContextProtocol/Microsoft.Extensions.AI 1.2.0 / 10.4.1's /// AIJsonUtilities.CreateFunctionJsonSchema, which checks ParameterInfo.IsOptional). This sweeps /// every [McpServerToolType] class in the External/ namespace so a future tool can't reintroduce /// a nullable-but-required filter parameter. /// public sealed class ExternalMcpToolSchemaTests { private static IEnumerable ExternalToolMethods() { var toolTypes = typeof(ExternalMcpService).Assembly.GetTypes() .Where(t => t.Namespace == typeof(ExternalMcpService).Namespace && t.GetCustomAttribute() is not null); foreach (var type in toolTypes) foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) { if (method.GetCustomAttribute() is not null) yield return method; } } [Fact] public void NoExternalTool_HasARequiredNullableParameter() { var nullabilityContext = new NullabilityInfoContext(); var violations = new List(); foreach (var method in ExternalToolMethods()) { var schema = AIJsonUtilities.CreateFunctionJsonSchema(method); var required = schema.TryGetProperty("required", out var requiredElement) ? requiredElement.EnumerateArray().Select(e => e.GetString()).ToHashSet() : new HashSet(); foreach (var parameter in method.GetParameters()) { if (parameter.ParameterType == typeof(CancellationToken)) continue; if (!required.Contains(parameter.Name)) continue; var isNullableValueType = Nullable.GetUnderlyingType(parameter.ParameterType) is not null; var isNullableRefType = !parameter.ParameterType.IsValueType && nullabilityContext.Create(parameter).WriteState == NullabilityState.Nullable; if (isNullableValueType || isNullableRefType) { violations.Add( $"{method.DeclaringType!.Name}.{method.Name}({parameter.Name}) is a nullable " + "type but has no default value, so MCP clients omitting it will fail. " + "Give it a default value (e.g. '= null')."); } } } Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations)); } [Fact] public void ExternalToolMethods_AreDiscovered() { // Guards the sweep itself: if this drops to 0, ExternalToolMethods() broke silently // (e.g. namespace/attribute mismatch) and the schema test above would pass vacuously. Assert.True(ExternalToolMethods().Count() > 20); } }