Summary. When two AIFunctions are created concurrently for the same method — for example two WebApplicationFactory hosts in one test process, each calling WithTools() — a parameter that should be excluded from the function's JSON schema because it's bound from DI can intermittently appear in that schema as a caller-supplied argument.
Only the published schema is affected. Binding is not: a caller's value for such a parameter is ignored, because the marshallers are built from the first GetParameters() array. So it's a contract bug, not a parameter-spoofing bug.
Mechanism. ReflectionAIFunctionDescriptor's constructor builds a Dictionary<ParameterInfo, ParameterBindingOptions> from a single GetParameters() call. AIJsonUtilities.CreateFunctionJsonSchema then calls GetParameters() a second time, and its IncludeParameter delegate looks each ParameterInfo up in that dictionary. ParameterInfo doesn't override Equals, so the lookup is by reference — and the runtime populates a method's ParameterInfo cache without a lock, so two first-time calls on different threads can receive distinct instances. The lookup then misses, IncludeParameter returns true, and the DI-bound parameter is emitted.
Parameters excluded by type before that lookup (IServiceProvider, AIFunctionArguments, CancellationToken) can't hit this path. Ones excluded through the reference-keyed dictionary can, which in the MCP SDK's case also covers ClaimsPrincipal, McpServer, RequestContext, IProgress and [FromKeyedServices].
Repro shape. A tool method with a typed DI parameter; build two hosts in the same process concurrently, each registering the same tool type; read the published inputSchema from each. We saw the parameter appear in 3 of 8 runs. It doesn't reproduce single-threaded, because the cache is already populated by then.
Suggested fixes. Key the dictionary on (MethodBase, position) or on the metadata token rather than the ParameterInfo reference; or pass the already-materialised array into CreateFunctionJsonSchema instead of calling GetParameters() again; or compute the exclusion set as positions.
Workaround (what we did): take IServiceProvider as the only injected parameter and resolve inside the method, since that type is excluded before the lookup.
Summary. When two AIFunctions are created concurrently for the same method — for example two WebApplicationFactory hosts in one test process, each calling WithTools() — a parameter that should be excluded from the function's JSON schema because it's bound from DI can intermittently appear in that schema as a caller-supplied argument.
Only the published schema is affected. Binding is not: a caller's value for such a parameter is ignored, because the marshallers are built from the first GetParameters() array. So it's a contract bug, not a parameter-spoofing bug.
Mechanism. ReflectionAIFunctionDescriptor's constructor builds a Dictionary<ParameterInfo, ParameterBindingOptions> from a single GetParameters() call. AIJsonUtilities.CreateFunctionJsonSchema then calls GetParameters() a second time, and its IncludeParameter delegate looks each ParameterInfo up in that dictionary. ParameterInfo doesn't override Equals, so the lookup is by reference — and the runtime populates a method's ParameterInfo cache without a lock, so two first-time calls on different threads can receive distinct instances. The lookup then misses, IncludeParameter returns true, and the DI-bound parameter is emitted.
Parameters excluded by type before that lookup (IServiceProvider, AIFunctionArguments, CancellationToken) can't hit this path. Ones excluded through the reference-keyed dictionary can, which in the MCP SDK's case also covers ClaimsPrincipal, McpServer, RequestContext, IProgress and [FromKeyedServices].
Repro shape. A tool method with a typed DI parameter; build two hosts in the same process concurrently, each registering the same tool type; read the published inputSchema from each. We saw the parameter appear in 3 of 8 runs. It doesn't reproduce single-threaded, because the cache is already populated by then.
Suggested fixes. Key the dictionary on (MethodBase, position) or on the metadata token rather than the ParameterInfo reference; or pass the already-materialised array into CreateFunctionJsonSchema instead of calling GetParameters() again; or compute the exclusion set as positions.
Workaround (what we did): take IServiceProvider as the only injected parameter and resolve inside the method, since that type is excluded before the lookup.