Skip to content

MCP tool schemas exclude DI services by type, closing a reflection race that leaked them into tools/list (issue-4075) - #4110

Merged
erikdarlingdata merged 2 commits into
devfrom
fix/4075-schema-cache-leak
Sep 24, 2026
Merged

erikdarlingdata merged 2 commits into
devfrom
fix/4075-schema-cache-leak

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

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 a
fresh closure per McpServerTool.Create call, 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:

  1. ReflectionAIFunctionDescriptor's constructor calls method.GetParameters() once and records each
    parameter's DI-service exclusion in a Dictionary<ParameterInfo, …>.
  2. It then calls AIJsonUtilities.CreateFunctionJsonSchema, which calls method.GetParameters() a
    second time; its IncludeParameter looks each parameter up in that dictionary.
  3. ParameterInfo has no Equals override, so the lookup compares object references.
  4. RuntimeMethodInfo's parameter-array cache fills non-atomically (m_parameters ??= ...). A concurrent
    first GetParameters() call on the SAME MethodInfo, from another thread, can return a different
    array of ParameterInfo objects.
  5. The lookup misses, the DI-service parameter is treated as a plain argument, and it leaks into the
    served schema — exactly what McpToolsListBudgetTests: lazy Measure() + service-leak diagnostic on unpinned params (#4075) #4094's diagnostic caught (IsService(type) = True on the build's own
    provider, build count = 1, get_alert_settings.postgres in the served schema).

Measured in a standalone repro against these exact package versions:

  • one thread: 0/5,000 leaked
  • a second thread racing GetParameters() on the same method: 37/5,000 leaked (25/3,000 in an earlier run)
  • same race with a by-type IncludeParameter guard: 0/5,000 leaked

Fix

PerformanceMonitor.Common/Mcp/McpSchemaCompat.cs, WithGeminiCompatibleTools: each tool now gets its
own copy of GeminiCompatSchemaOptions (via a new internal SchemaOptionsFor(IServiceProvider)) with an
IncludeParameter that 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 ParameterInfo identity — it always sees the parameter's
correct declared type. The shared static GeminiCompatSchemaOptions instance is left unmutated (the with
expression on the record copies it); only the IncludeParameter delegate differs per provider. Parameter
BINDING (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.IsAugmentedWith recognises) take the same double-GetParameters() path and are
not 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 methods T(Svc svc, int x) via System.Reflection.Emit (so each
method's parameter cache starts empty every run) and races a bystander thread's GetParameters() call
against the tool build.

  • RaceGetParameters_WithByTypeGuard_NeverLeaksTheServiceIntoTheSchema (2,000 methods, through
    McpSchemaCompat.SchemaOptionsFor, the exact factory the production path now uses): 0/2,000 leaked.
  • RaceGetParameters_WithoutTheGuard_CanLeakTheServiceIntoTheSchema (5,000 methods, plain
    AIJsonSchemaCreateOptions with no guard, mirroring the pre-fix path): reproduced the leak — asserts
    leaks > 0 rather than an exact count (thread scheduling isn't deterministic across machines), and on
    this 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 restored
after):

  • McpSchemaCompatServiceLeakRaceTests: 2 total, 0 failed.
  • McpToolsListBudgetTests + McpUnknownArgumentGuardTests + McpToolGuideTests: 21 total, 0 failed.
  • Lite.Tests: build only (dotnet build), succeeded — 0 warnings, 0 errors. Execution left to CI (WPF).
  • Full Darling.Tests suite once: 13,310 total, 216 failed, 714 skipped. None of the failures are in
    McpSchemaCompat*, McpToolsListBudget*, McpUnknownArgumentGuard*, McpToolGuide*, or any Alert
    tools 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

  • Which specific concurrent test thread races the budget build inside the real full-suite run; this PR
    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.
  • The dormant risk in a live host process (another thread reflecting over a tool method during MCP server
    startup) — not exercised by any test here or before.

Coordinator verification (branch head a465a2c)

  • The unguarded-race repro is now [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.
  • The full Darling suite on macOS, compared with dev on the same rig: the only difference was DocCommentHygieneTests ×2, a <see cref="ModuleBuilder"/> that resolves to nothing the repo spells. Made it prose; the class passes 77/77.
  • McpSchemaCompatServiceLeakRaceTests 2/2 (+1 explicit), and McpToolsListBudgetTests 5/5.
  • The fix only ever removes a parameter from a schema, and only when its type is a registered DI service, which is exactly what the SDK meant to do. So a genuine argument can't be dropped unless its type is registered as a service.
  • CI sightings of this flake so far: get_alert_history.dataService (Lite, ×3), get_alert_settings.postgres and get_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.

…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 erikdarlingdata changed the title Fix #4075: guard MCP schema build against a GetParameters() identity race MCP tool schemas exclude DI services by type, closing a reflection race that leaked them into tools/list (issue-4075) Sep 24, 2026
@erikdarlingdata
erikdarlingdata marked this pull request as ready for review September 24, 2026 02:35
@erikdarlingdata
erikdarlingdata enabled auto-merge (squash) September 24, 2026 02:35
@erikdarlingdata
erikdarlingdata merged commit b1a7def into dev Sep 24, 2026
16 of 18 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/4075-schema-cache-leak branch September 24, 2026 02:47
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
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant