Skip to content

[Server] Defer element loading to first registry read#389

Open
soyuka wants to merge 1 commit into
modelcontextprotocol:mainfrom
soyuka:feat/lazy-registry-loading-v2
Open

[Server] Defer element loading to first registry read#389
soyuka wants to merge 1 commit into
modelcontextprotocol:mainfrom
soyuka:feat/lazy-registry-loading-v2

Conversation

@soyuka

@soyuka soyuka commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Builder::build() runs all loaders eagerly (ChainLoader::load($registry)) and then snapshots server capabilities from that registry (tools: $registry->hasTools(), etc.). Both happen once, when the server is built.

Under a persistent runtime (e.g. FrankenPHP worker mode) the server is built a single time and reused across requests. If a loader's data source is not yet ready at that moment — a cold cache, an application metadata layer that gets warmed later — the registry captures an empty state and never recovers for the lifetime of the process. tools/list (which reads the registry) returns [], while tools/call can still work if the consumer resolves handlers independently of the registry. This was reported downstream in api-platform/core#8370 (FrankenPHP worker mode).

Change

  • Add Mcp\Capability\LazyRegistry, a registry decorator that runs the loaders on the first read instead of at build time. Loading moves to request time, when the application is fully initialized; it runs once per process, and registrations made before the first read are preserved (loader writes are additive/idempotent by name).
  • The deferred load is retried on the next read if it throws: the loaded flag is set only after a successful load, so a transient failure at the first read (data source still not ready) does not permanently freeze an empty registry for the whole process.
  • In Builder::build(), wrap the registry in LazyRegistry instead of calling $loader->load() eagerly.
  • Advertise capabilities from the configured element sources ($this->tools, $this->explicitTools, discovery/custom loaders, …) rather than from the now-lazy registry, so the initialize handshake does not force an eager load. Custom loaders and discovery are opaque about which element kinds they yield, so their presence advertises tools/resources/prompts; over-advertising is harmless (a client lists and gets an empty result).
  • A registry supplied via setRegistry() is treated as an opaque source too, so a pre-populated custom registry still advertises its capabilities (it previously advertised false after the switch away from inspecting the registry, which would make a spec-compliant client skip tools/list entirely).

This also removes the need for the userland workarounds consumers currently apply (request-time list handlers, restoring-registry decorators) to keep discovery working under worker runtimes.

Tests

  • New LazyRegistryTest: loader not run until first read, runs on first read and populates, runs exactly once across many reads, runtime registrations survive the deferred load, and testLoaderRetriesAfterAFailedLoad (a throwing first load is retried on the next read).
  • New BuilderTest::testBuildAdvertisesToolsForPreloadedCustomRegistry: a registry pre-populated via setRegistry() advertises tools in the initialize handshake.
  • Existing BuilderTest and the full suite pass. PHP-CS-Fixer and PHPStan clean.

Notes

Behavioral change worth flagging for review: capabilities are now derived from configured sources, so a server with a discovery path or custom loader that ultimately yields nothing will advertise tools/resources/prompts where it previously advertised false. This is intentional (the whole point is not to inspect the registry at build), and harmless per MCP semantics, but it is a visible difference in the initialize response.

@soyuka soyuka force-pushed the feat/lazy-registry-loading-v2 branch 2 times, most recently from 1424746 to 91b4290 Compare July 1, 2026 06:52
Builder::build() runs all loaders eagerly and snapshots capabilities from
the resulting registry. Both are computed once, when the server is built.
Under a persistent runtime (e.g. FrankenPHP worker mode) the server is
built a single time, so a loader whose data source is not yet ready at
that moment (cold cache, un-warmed metadata) leaves the registry empty for
the whole process — tools/list stays empty while tools/call still works.

Wrap the registry in a LazyRegistry that runs the loaders on the first
read, moving loading to request time when the application is initialized.
The load is retried on the next read if it throws, so a transient failure
at the first read does not freeze an empty registry for the whole process.

Advertise capabilities from the configured element sources instead of the
loaded registry, so the initialize handshake does not force an eager load.
A registry supplied via setRegistry() counts as an opaque source too, so a
pre-populated custom registry still advertises its capabilities.
@soyuka soyuka force-pushed the feat/lazy-registry-loading-v2 branch from 91b4290 to d393af6 Compare July 1, 2026 07:57
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