Repository navigation
MCP tool schemas exclude DI services by type, closing a reflection race that leaked them into tools/list (issue-4075) - #4110
Merged
Conversation
…race McpSchemaCompat.WithGeminiCompatibleTools now gives every tool a per-provider copy of the Gemini-compat schema options with an IncludeParameter callback that excludes DI-service-typed parameters by TYPE (IServiceProviderIsService), rather than relying solely on the SDK's own ParameterInfo-identity-keyed exclusion. That identity-keyed lookup can miss under a reflection race: RuntimeMethodInfo.GetParameters() fills its cache non-atomically, so a concurrent first call on the same MethodInfo from another thread can hand back a different ParameterInfo array, and the second (schema-building) call inside AIJsonUtilities.CreateFunctionJsonSchema misses the DI-service parameter on identity and lets it leak into the served schema. Adds Darling.Tests/McpSchemaCompatServiceLeakRaceTests: reflection-emitted throwaway tool methods raced against a bystander thread's GetParameters() call, proving both that the race leaks a DI service into the schema without the guard and that it cannot with it.
…a race doesn't happen is a flake), and make ModuleBuilder prose, not a cref
erikdarlingdata
marked this pull request as ready for review
September 24, 2026 02:35
erikdarlingdata
enabled auto-merge (squash)
September 24, 2026 02:35
erikdarlingdata
added a commit
that referenced
this pull request
Sep 24, 2026
…ider schema options McpSchemaCompat.cs conflicted with #4110 (#4075). Both sides stay: the alwaysLoad name set and Meta, and SchemaOptionsFor(services) in place of the shared GeminiCompatSchemaOptions. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUU29PuGg9TUFBsACgZg2K
This was referenced Sep 24, 2026
erikdarlingdata
added a commit
that referenced
this pull request
Sep 24, 2026
* Add 48 buffered CHANGELOG entries in one splice Adds 48 entries and 48 link refs (#4057, #4077, #4079, #4081, #4082, #4083, #4084, #4085, #4086, #4087, #4088, #4089, #4090, #4091, #4092, #4093, #4095, #4096, #4099, #4100, #4101, #4103, #4105, #4106, #4107, #4108, #4109, #4110, #4111, #4113, #4114, #4115, #4116, #4117, #4118, #4119, #4120, #4121, #4122, #4123, #4124, #4125, #4126, #4127, #4131, #4133, #4136, #4141). Each PR's entry was buffered, and this lands every entry whose PR was merged on origin/dev when it ran. Entries found under a bare section line (none unless the old script ran again) move into the matching ### section, below the new entries. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQh8LczP1HGLXQYAx4oTZC * Changelog splice: add #4137's entry, which merged before the splice but was missed #4137 (pg_plan_capture reads csvlog) merged at 05:57Z with a CHANGELOG entry section in its body. It was neither spliced nor listed as needing no entry. Its entry goes under Fixed, right after #4136's, and #4136's last sentence now points to it instead of saying plan capture is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUU29PuGg9TUFBsACgZg2K --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This fixes the flake tracked in issue-4075. The issue stays open until two weeks of clean CI.
Root cause (credit: pr-tender seat's repro, issue comment 5806331396)
A reflection race, not the static-cache theory from the earlier lane's decompile (that theory is
ruled out: the SDK's descriptor cache key includes
ConfigureParameterBinding, and the SDK makes afresh closure per
McpServerTool.Createcall, so it can't be shared between two service providers).What happens, read in the decompiled ModelContextProtocol.Core 2.2.0, Microsoft.Extensions.AI.Abstractions
10.8.3, and the .NET 10.0.12 runtime:
ReflectionAIFunctionDescriptor's constructor callsmethod.GetParameters()once and records eachparameter's DI-service exclusion in a
Dictionary<ParameterInfo, …>.AIJsonUtilities.CreateFunctionJsonSchema, which callsmethod.GetParameters()asecond time; its
IncludeParameterlooks each parameter up in that dictionary.ParameterInfohas noEqualsoverride, so the lookup compares object references.RuntimeMethodInfo's parameter-array cache fills non-atomically (m_parameters ??= ...). A concurrentfirst
GetParameters()call on the SAMEMethodInfo, from another thread, can return a differentarray of
ParameterInfoobjects.served schema — exactly what McpToolsListBudgetTests: lazy Measure() + service-leak diagnostic on unpinned params (#4075) #4094's diagnostic caught (
IsService(type) = Trueon the build's ownprovider,
build count = 1,get_alert_settings.postgresin the served schema).Measured in a standalone repro against these exact package versions:
GetParameters()on the same method: 37/5,000 leaked (25/3,000 in an earlier run)IncludeParameterguard: 0/5,000 leakedFix
PerformanceMonitor.Common/Mcp/McpSchemaCompat.cs,WithGeminiCompatibleTools: each tool now gets itsown copy of
GeminiCompatSchemaOptions(via a new internalSchemaOptionsFor(IServiceProvider)) with anIncludeParameterthat excludes DI-service-typed parameters by CLR type (IServiceProviderIsService),ANDed with the SDK's own identity-keyed lookup. A missed identity lookup can no longer let a service
through, because this check never depends on
ParameterInfoidentity — it always sees the parameter'scorrect declared type. The shared static
GeminiCompatSchemaOptionsinstance is left unmutated (thewithexpression on the record copies it); only the
IncludeParameterdelegate differs per provider. ParameterBINDING (which value is passed at invocation) is unaffected — it consistently uses the first
GetParameters()array; only the served SCHEMA was at risk. Both Darling and Lite go through this one file.Out of scope, noted for later: the SDK's own request-scoped parameter types (the ones
RequestServiceProvider.IsAugmentedWithrecognises) take the same double-GetParameters()path and arenot covered by this by-type guard — no Darling/Lite tool takes one of those parameter types today, so this
is dormant risk, not a live gap. Also out of scope: reporting the upstream double-
GetParameters()/identity-keyed-lookup design to Microsoft.Extensions.AI — the root cause is theirs, not documented here as
a follow-up issue per this lane's brief (mechanical fix only).
Test
New
Darling/Darling.Tests/McpSchemaCompatServiceLeakRaceTests.cs: emits fresh throwaway[McpServerTool]-decorated static methodsT(Svc svc, int x)viaSystem.Reflection.Emit(so eachmethod's parameter cache starts empty every run) and races a bystander thread's
GetParameters()callagainst the tool build.
RaceGetParameters_WithByTypeGuard_NeverLeaksTheServiceIntoTheSchema(2,000 methods, throughMcpSchemaCompat.SchemaOptionsFor, the exact factory the production path now uses): 0/2,000 leaked.RaceGetParameters_WithoutTheGuard_CanLeakTheServiceIntoTheSchema(5,000 methods, plainAIJsonSchemaCreateOptionswith no guard, mirroring the pre-fix path): reproduced the leak — assertsleaks > 0rather than an exact count (thread scheduling isn't deterministic across machines), and onthis run it did leak, confirming the race is real on this box before asserting the fix closes it.
Ran (this Mac,
dotnet <dll> -class ..., WindowsDesktop.App stripped from runtimeconfig.json and restoredafter):
McpSchemaCompatServiceLeakRaceTests: 2 total, 0 failed.McpToolsListBudgetTests+McpUnknownArgumentGuardTests+McpToolGuideTests: 21 total, 0 failed.dotnet build), succeeded — 0 warnings, 0 errors. Execution left to CI (WPF).McpSchemaCompat*,McpToolsListBudget*,McpUnknownArgumentGuard*,McpToolGuide*, or any Alerttools test class — grepped the full-run failure list for those names, zero hits. The 216 failures are
the pre-existing Windows-only failure set this Mac's rig always shows (brief cites 214; the small
drift is consistent with the usual noise on this rig, not a regression from this change).
Not verified
proves the mechanism in isolation (per the brief), not which exact pair of classes collides in CI. The
fix closes the mechanism regardless of which thread wins.
startup) — not exercised by any test here or before.
Coordinator verification (branch head a465a2c)
[Fact(Explicit = true)]. It asserts the race happens (leaks > 0), so on a slow or lightly loaded runner it would fail whenever the window never opens. That would be a new flake in a PR that fixes a flake. It runs on demand (-method ... -explicit on), and it passed on demand here. The guarded test (0 of 2,000 leaks) is the one CI runs.DocCommentHygieneTests×2, a<see cref="ModuleBuilder"/>that resolves to nothing the repo spells. Made it prose; the class passes 77/77.McpSchemaCompatServiceLeakRaceTests2/2 (+1 explicit), andMcpToolsListBudgetTests5/5.get_alert_history.dataService(Lite, ×3),get_alert_settings.postgresandget_ag_health.postgres(Darling). All match the mechanism.CHANGELOG entry
Fixed a race in MCP tool-schema generation that could leak a dependency-injected service (e.g. the
PostgreSQL connection) into a tool's advertised parameter schema under concurrent test/build load.