Skip to content

Commit 31f987c

Browse files
Honor UnmappedMemberHandling in AIFunctionFactory parameter binding (#7474)
* Honor UnmappedMemberHandling in AIFunctionFactory parameter binding When JsonSerializerOptions.UnmappedMemberHandling is set to Disallow, AIFunction invocations will now throw ArgumentException if the provided AIFunctionArguments contains keys that do not correspond to any declared parameter of the underlying method. This mirrors STJ's handling of unmapped properties for object deserialization and enables opt-in strict validation of tool call arguments. Addresses discussion in modelcontextprotocol/csharp-sdk#1508. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use is pattern and honor AIFunctionArguments comparer for match check Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR feedback: parameterless methods, BindParameter bypass, docs - Always build the expected-argument-name set (even if empty) so strict mode also flags extra keys on parameterless methods / methods with only infrastructure parameters. - Skip strict unmapped-key validation when any parameter uses a custom ParameterBindingOptions.BindParameter callback, since those may legitimately consume arbitrary argument keys. - Expanded the XML docs on AIFunctionFactoryOptions.SerializerOptions to clarify which parameter names are considered valid and that custom parameter binding bypasses the strict check. - Added tests for both new behaviors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Condense SerializerOptions remark on UnmappedMemberHandling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6057d20 commit 31f987c

3 files changed

Lines changed: 208 additions & 0 deletions

File tree

src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
using System.Runtime.CompilerServices;
1616
using System.Text.Json;
1717
using System.Text.Json.Nodes;
18+
using System.Text.Json.Serialization;
1819
using System.Text.Json.Serialization.Metadata;
1920
using System.Text.RegularExpressions;
2021
using System.Threading;
@@ -627,6 +628,49 @@ private ReflectionAIFunction(
627628
var paramMarshallers = FunctionDescriptor.ParameterMarshallers;
628629
object?[] args = paramMarshallers.Length != 0 ? new object?[paramMarshallers.Length] : [];
629630

631+
// If the configured serializer options request strict handling of unmapped members,
632+
// verify that every argument key corresponds to a declared parameter name. This mirrors
633+
// JsonSerializerOptions.UnmappedMemberHandling behavior for object deserialization by
634+
// applying the same policy to top-level AIFunction argument binding. Argument name matching
635+
// honors the comparer of the supplied AIFunctionArguments dictionary (ordinal by default).
636+
//
637+
// Validation is skipped when custom ParameterBindingOptions.BindParameter callbacks are in
638+
// use, since those may legitimately source values from argument keys that do not correspond
639+
// to the .NET parameter names.
640+
if (FunctionDescriptor.JsonSerializerOptions.UnmappedMemberHandling is JsonUnmappedMemberHandling.Disallow &&
641+
arguments.Count > 0 &&
642+
!FunctionDescriptor.HasCustomParameterBinding)
643+
{
644+
HashSet<string> expectedNames = FunctionDescriptor.ExpectedArgumentNames;
645+
int matched = 0;
646+
foreach (string name in expectedNames)
647+
{
648+
if (arguments.ContainsKey(name))
649+
{
650+
matched++;
651+
}
652+
}
653+
654+
if (matched != arguments.Count)
655+
{
656+
foreach (KeyValuePair<string, object?> kvp in arguments)
657+
{
658+
if (!expectedNames.Contains(kvp.Key))
659+
{
660+
Throw.ArgumentException(
661+
nameof(arguments),
662+
$"The arguments dictionary contains an unexpected key '{kvp.Key}' that does not correspond to any parameter of '{Name}'.");
663+
}
664+
}
665+
666+
// Fallback for comparer mismatches (e.g. case-insensitive arguments dictionary
667+
// with duplicate-casing keys aliasing to the same parameter).
668+
Throw.ArgumentException(
669+
nameof(arguments),
670+
$"The arguments dictionary contains keys that do not correspond to any parameter of '{Name}'.");
671+
}
672+
}
673+
630674
for (int i = 0; i < args.Length; i++)
631675
{
632676
args[i] = paramMarshallers[i](arguments, cancellationToken);
@@ -733,6 +777,8 @@ private ReflectionAIFunctionDescriptor(DescriptorKey key, JsonSerializerOptions
733777

734778
// Get marshaling delegates for parameters.
735779
ParameterMarshallers = parameters.Length > 0 ? new Func<AIFunctionArguments, CancellationToken, object?>[parameters.Length] : [];
780+
HashSet<string> expectedArgumentNames = new(StringComparer.Ordinal);
781+
bool hasCustomParameterBinding = false;
736782
for (int i = 0; i < parameters.Length; i++)
737783
{
738784
if (boundParameters?.TryGetValue(parameters[i], out AIFunctionFactoryOptions.ParameterBindingOptions options) is not true)
@@ -741,8 +787,32 @@ private ReflectionAIFunctionDescriptor(DescriptorKey key, JsonSerializerOptions
741787
}
742788

743789
ParameterMarshallers[i] = GetParameterMarshaller(serializerOptions, options, parameters[i]);
790+
791+
if (options.BindParameter is not null)
792+
{
793+
// Custom BindParameter callbacks can legally source their value from arbitrary keys in the
794+
// AIFunctionArguments dictionary, so we cannot know in advance which keys are "expected".
795+
// Note this down so that strict unmapped-member validation is skipped in InvokeCoreAsync.
796+
hasCustomParameterBinding = true;
797+
}
798+
799+
// Collect the set of parameter names that are potentially sourced from the arguments dictionary.
800+
// Infrastructure parameters (CancellationToken, AIFunctionArguments, IServiceProvider) are always
801+
// bound from dedicated sources and are never resolved by argument name, so they are excluded from
802+
// the permitted set.
803+
Type pType = parameters[i].ParameterType;
804+
if (pType != typeof(CancellationToken) &&
805+
pType != typeof(AIFunctionArguments) &&
806+
pType != typeof(IServiceProvider) &&
807+
!string.IsNullOrEmpty(parameters[i].Name))
808+
{
809+
_ = expectedArgumentNames.Add(parameters[i].Name!);
810+
}
744811
}
745812

813+
ExpectedArgumentNames = expectedArgumentNames;
814+
HasCustomParameterBinding = hasCustomParameterBinding;
815+
746816
ReturnParameterMarshaller = GetReturnParameterMarshaller(key, serializerOptions, out Type? returnType);
747817
Method = key.Method;
748818
Name = key.Name ?? key.Method.GetCustomAttribute<DisplayNameAttribute>(inherit: true)?.DisplayName ?? GetFunctionName(key.Method);
@@ -770,6 +840,8 @@ private ReflectionAIFunctionDescriptor(DescriptorKey key, JsonSerializerOptions
770840
public JsonElement? ReturnJsonSchema { get; }
771841
public Func<AIFunctionArguments, CancellationToken, object?>[] ParameterMarshallers { get; }
772842
public Func<object?, CancellationToken, ValueTask<object?>> ReturnParameterMarshaller { get; }
843+
public HashSet<string> ExpectedArgumentNames { get; }
844+
public bool HasCustomParameterBinding { get; }
773845
public ReflectionAIFunction? CachedDefaultInstance { get; set; }
774846

775847
private static string GetFunctionName(MethodInfo method)

src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,15 @@ public AIFunctionFactoryOptions()
2525

2626
/// <summary>Gets or sets the <see cref="JsonSerializerOptions"/> used to marshal .NET values being passed to the underlying delegate.</summary>
2727
/// <remarks>
28+
/// <para>
2829
/// If no value has been specified, the <see cref="AIJsonUtilities.DefaultOptions"/> instance will be used.
30+
/// </para>
31+
/// <para>
32+
/// The <see cref="JsonSerializerOptions.UnmappedMemberHandling"/> setting is honored by the function parameter
33+
/// binder: when set to <see cref="System.Text.Json.Serialization.JsonUnmappedMemberHandling.Disallow"/>, invoking
34+
/// the produced <see cref="AIFunction"/> throws if the supplied <see cref="AIFunctionArguments"/> contains keys
35+
/// that do not correspond to a bindable parameter of the underlying method.
36+
/// </para>
2937
/// </remarks>
3038
public JsonSerializerOptions? SerializerOptions { get; set; }
3139

test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1455,6 +1455,134 @@ public async Task AIFunctionFactory_DynamicMethod()
14551455
#endif
14561456
}
14571457

1458+
[Fact]
1459+
public async Task Parameters_UnmappedMemberHandlingDisallow_ThrowsOnExtraArgument_Async()
1460+
{
1461+
JsonSerializerOptions strictOptions = new(AIJsonUtilities.DefaultOptions)
1462+
{
1463+
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
1464+
};
1465+
1466+
AIFunction func = AIFunctionFactory.Create(
1467+
(string taskId, string update, bool markComplete = false) => $"{taskId}:{update}:{markComplete}",
1468+
new AIFunctionFactoryOptions { SerializerOptions = strictOptions });
1469+
1470+
// Extra, unrecognized argument causes a throw.
1471+
ArgumentException ex = await Assert.ThrowsAsync<ArgumentException>("arguments", async () =>
1472+
await func.InvokeAsync(new()
1473+
{
1474+
["taskId"] = "abc",
1475+
["update"] = "Done",
1476+
["phase"] = "completed",
1477+
}));
1478+
Assert.Contains("phase", ex.Message);
1479+
1480+
// Still succeeds when no unexpected arguments are present (optional parameter omitted).
1481+
object? result = await func.InvokeAsync(new()
1482+
{
1483+
["taskId"] = "abc",
1484+
["update"] = "Done",
1485+
});
1486+
AssertExtensions.EqualFunctionCallResults("abc:Done:False", result);
1487+
}
1488+
1489+
[Fact]
1490+
public async Task Parameters_UnmappedMemberHandlingDefault_IgnoresExtraArgument_Async()
1491+
{
1492+
// Default behavior (Skip) should preserve pre-existing lenient binding.
1493+
AIFunction func = AIFunctionFactory.Create(
1494+
(string update, bool markComplete = false) => $"{update}:{markComplete}");
1495+
1496+
object? result = await func.InvokeAsync(new()
1497+
{
1498+
["update"] = "Done",
1499+
["phase"] = "completed",
1500+
});
1501+
AssertExtensions.EqualFunctionCallResults("Done:False", result);
1502+
}
1503+
1504+
[Fact]
1505+
public async Task Parameters_UnmappedMemberHandlingDisallow_HonorsArgumentsComparer_Async()
1506+
{
1507+
JsonSerializerOptions strictOptions = new(AIJsonUtilities.DefaultOptions)
1508+
{
1509+
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
1510+
};
1511+
1512+
AIFunction func = AIFunctionFactory.Create(
1513+
(string update, bool markComplete = false) => $"{update}:{markComplete}",
1514+
new AIFunctionFactoryOptions { SerializerOptions = strictOptions });
1515+
1516+
// Case-insensitive arguments dictionary: casing variations of the parameter name must not be
1517+
// flagged as unmapped, since the binding lookup itself is case-insensitive.
1518+
AIFunctionArguments caseInsensitive = new(StringComparer.OrdinalIgnoreCase)
1519+
{
1520+
["UPDATE"] = "Done",
1521+
["MarkComplete"] = true,
1522+
};
1523+
AssertExtensions.EqualFunctionCallResults("Done:True", await func.InvokeAsync(caseInsensitive));
1524+
1525+
// A genuinely unmapped key is still flagged even with a case-insensitive comparer.
1526+
AIFunctionArguments withExtra = new(StringComparer.OrdinalIgnoreCase)
1527+
{
1528+
["update"] = "Done",
1529+
["PHASE"] = "completed",
1530+
};
1531+
ArgumentException ex = await Assert.ThrowsAsync<ArgumentException>("arguments", async () =>
1532+
await func.InvokeAsync(withExtra));
1533+
Assert.Contains("PHASE", ex.Message);
1534+
}
1535+
1536+
[Fact]
1537+
public async Task Parameters_UnmappedMemberHandlingDisallow_ParameterlessMethod_ThrowsOnAnyArgument_Async()
1538+
{
1539+
JsonSerializerOptions strictOptions = new(AIJsonUtilities.DefaultOptions)
1540+
{
1541+
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
1542+
};
1543+
1544+
AIFunction func = AIFunctionFactory.Create(
1545+
() => "ok",
1546+
new AIFunctionFactoryOptions { SerializerOptions = strictOptions });
1547+
1548+
// No args is fine.
1549+
AssertExtensions.EqualFunctionCallResults("ok", await func.InvokeAsync());
1550+
1551+
// Any extra key is flagged.
1552+
ArgumentException ex = await Assert.ThrowsAsync<ArgumentException>("arguments", async () =>
1553+
await func.InvokeAsync(new() { ["phase"] = "completed" }));
1554+
Assert.Contains("phase", ex.Message);
1555+
}
1556+
1557+
[Fact]
1558+
public async Task Parameters_UnmappedMemberHandlingDisallow_CustomBindParameter_SkipsStrictValidation_Async()
1559+
{
1560+
JsonSerializerOptions strictOptions = new(AIJsonUtilities.DefaultOptions)
1561+
{
1562+
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
1563+
};
1564+
1565+
// A custom BindParameter callback sources its value from a key that does not correspond
1566+
// to the .NET parameter name. Strict validation must be skipped so such binders keep working.
1567+
AIFunction func = AIFunctionFactory.Create(
1568+
(string update) => $"update:{update}",
1569+
new AIFunctionFactoryOptions
1570+
{
1571+
SerializerOptions = strictOptions,
1572+
ConfigureParameterBinding = _ => new()
1573+
{
1574+
BindParameter = (_, args) => args["aliasedKey"],
1575+
},
1576+
});
1577+
1578+
object? result = await func.InvokeAsync(new()
1579+
{
1580+
["aliasedKey"] = "hello",
1581+
["anotherKey"] = "world",
1582+
});
1583+
AssertExtensions.EqualFunctionCallResults("update:hello", result);
1584+
}
1585+
14581586
[JsonSerializable(typeof(IAsyncEnumerable<int>))]
14591587
[JsonSerializable(typeof(int[]))]
14601588
[JsonSerializable(typeof(string))]

0 commit comments

Comments
 (0)