Skip to content

MCP analyze_server: overlapping calls each analyze their own server (#4726) - #4742

Merged
erikdarlingdata merged 2 commits into
devfrom
fix/4726-analyze-server-per-call
Sep 29, 2026
Merged

erikdarlingdata merged 2 commits into
devfrom
fix/4726-analyze-server-per-call

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Fixes #4726

Why

Both MCP hosts (Darling and Lite) registered ONE analysis service for the life of the process. While a pass is running, AnalyzeAsync returns an empty list at its busy check, and the analyze_server tool then reads the running pass's state. A second, overlapping call therefore answered "No significant findings. All metrics are within normal ranges" for a server it never analyzed. MCP clients often call tools in parallel, so the window is the whole length of the first pass. Scheduled analysis was not affected.

What changes

  • Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs: the analysis service is registered per call, in a new internal static RegisterAnalysisService(services, postgres, planFetcher, logger, baselineCache, analyzer) that the host calls. It does AddTransient<DarlingAnalysisService>(_ => new DarlingAnalysisService(postgres, planFetcher, logger, baselineCache, analyzer ?? AnalyzerConfig.Default)): one instance per MCP call, the way the worker builds one per pass. The host still passes its ONE shared _baselineCache (Every scheduled analysis pass recomputes every 30-day baseline: a fresh DarlingAnalysisService per pass never reuses (or shares with MCP) the bucket cache #3941), so baselines are not recomputed per call.
  • Lite/Mcp/McpHostService.cs: the same change. RegisterAnalysisService(services, duckDb, planFetcher, serverManager, schedules) does AddTransient<AnalysisService> and still passes BaselineCache.For(duckDb); the host calls it with its own store. Lite's scheduler and Recommendations tab keep their own instances and are untouched.
  • DarlingAnalysisService and Lite's AnalysisService keep the shared baseline cache they were handed and expose it as an internal read-only SharedBaselineCache, so a test can compare the caches of two per-call instances. Nothing else about the services changes.
  • No semaphore: one instance per call makes it unnecessary. The web host also shares one instance but never calls AnalyzeAsync, so it is left alone.
  • McpServiceParameterDiSeatCensusTests now accepts an AddTransient<T> seat as well as the singleton forms (a transient seat still means the parameter is resolved from DI and never served as a client argument). The six-type pin is unchanged.
  • McpToolLatencyRecordingTests registers the analysis service per call, with one shared BaselineCache, like the production host.
  • SharedBaselineCacheTests (in Darling.Tests and in Lite.Tests): two source pins followed the registration into the extracted method. Darling's now pins both halves, the construction inside RegisterAnalysisService and the host's call that passes _baselineCache. Lite's per-file check now looks for BaselineCache.For(duckDb) in McpHostService.cs (the parameter name in the extracted method); the host's call that passes _duckDb is pinned in AnalyzeServerPerCallAnalysisServiceTests.

New tests:

  • Darling.Tests AnalyzeServerPerCallAnalysisServiceTests.TwoResolutionsOfTheHostRegistration_AreDistinctServices_ThatShareTheOneBaselineCache: registers through DarlingMcpHostService.RegisterAnalysisService on a fresh ServiceCollection, resolves the service twice, and asserts the instances are NotSame and their SharedBaselineCache is Same (and is the cache passed in). No store is needed: creating the data source connects to nothing. The existing source pin also requires the host to call RegisterAnalysisService with its _baselineCache.
  • Lite.Tests AnalyzeServerPerCallAnalysisServiceTests.TwoResolutionsOfTheHostRegistration_AreDistinctServices_ThatShareTheStoresOneBaselineCache: the same through McpHostService.RegisterAnalysisService, and the shared cache must be BaselineCache.For(duckDb). The source pin now also requires the host to call the method with _duckDb.
  • Lite.Tests AnalyzeServerOverlappingCallsTests.ASecondCallWhileTheFirstIsAnalyzing_AnalyzesItsOwnServer_InsteadOfTheBusyAllClear, in its own non-parallel collection (lite-analyze-server-overlap) because the store's database lock is one process-wide lock. A dedicated thread takes the store's write lock (the lock is thread-affine, so the same thread releases it, and a finally releases it if an assertion fails). Analyze_server call A (server 1) starts and the test waits until its service reports IsAnalyzing. Call B (server 2) starts, and the test waits until B has either returned (the shared-instance shape) or started analyzing on its own service. Then the lock is released and both calls are awaited. B must not be the "No significant findings. All metrics are within normal ranges" all-clear. No data is collected for either server, so each call answers insufficient_data for its own server, and the test asserts that too.
  • Darling.Tests gets no end-to-end overlap test: its analyze_server resolves the server through the store's registry before it analyzes, so that test would need a live store; the registration test above covers Darling's registration.

Test plan

  • Darling.Tests and Lite.Tests build with 0 warnings, 0 errors.
  • RED against a singleton registration: with the extracted method switched to AddSingleton for one run (not committed), Lite.Tests failed 3 of 3 (the two-resolution test with Assert.NotSame() Failure: Values are the same instance, the source pin, and the overlap test, where call B returned the all-clear and Assert.DoesNotContain failed) and Darling.Tests failed 2 of 2 (the two-resolution test on NotSame, and the source pin). Restored to AddTransient, all pass.
  • Targeted classes after the change: Lite.Tests (AnalyzeServerPerCallAnalysisServiceTests, AnalyzeServerOverlappingCallsTests, SharedBaselineCacheTests) Total 10, Failed 0. Darling.Tests (AnalyzeServerPerCallAnalysisServiceTests, McpServiceParameterDiSeatCensusTests, McpToolLatencyRecordingTests, SharedBaselineCacheTests, SharedBaselineCacheLiveTests) Total 18, Failed 0, Skipped 6 (the live tests need DARLING_TEST_PG).
  • Full Lite.Tests suite: Total 5588, Failed 0, Skipped 0.
  • Full Darling.Tests suite, run without DARLING_TEST_PG: Total 17086, Failed 0, Skipped 1111 (the live tests skip without it), Not Run 1 (a test marked explicit-only).
  • Live PostgreSQL tests: not run here; CI runs them.

CHANGELOG

SECTION: Fixed
ENTRY:

…ls each analyze their own server (#4726)

Both MCP hosts registered a single shared analysis service. While a pass ran, AnalyzeAsync returned an empty list and the tool read the running pass's state, so a second overlapping analyze_server call answered 'No significant findings' for a server it never analyzed. Darling and Lite now register the service transient, one instance per call as the worker builds one per pass, and still hand every instance the one shared BaselineCache. McpServiceParameterDiSeatCensusTests now accepts a transient seat, and McpToolLatencyRecordingTests registers the service per call like the production host, both on purpose for the new registration.
…pping-call test (#4726)

Each MCP host registers its analysis service through an internal static RegisterAnalysisService that the host calls and a test also calls, so a test resolves the production registration instead of a copy. Both analysis services expose the shared baseline cache they were handed (SharedBaselineCache, internal).

Darling.Tests and Lite.Tests resolve the service twice from a fresh collection and assert NotSame instances that share the Same baseline cache. Lite.Tests also overlaps two analyze_server calls for different servers behind a held store write lock (non-parallel collection, lock held on a dedicated thread) and asserts the second call is not the busy-path all-clear. The two SharedBaselineCacheTests source pins follow the registration into the extracted method.
@erikdarlingdata
erikdarlingdata marked this pull request as ready for review September 29, 2026 08:29
@erikdarlingdata
erikdarlingdata merged commit ed6ee6a into dev Sep 29, 2026
17 of 18 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/4726-analyze-server-per-call branch September 29, 2026 08:32
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