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 3 times, most recently from 91b4290 to d393af6 Compare July 1, 2026 07:57
@chr-hertel chr-hertel modified the milestones: 0.6.0, 0.7.0 Jul 5, 2026
@chr-hertel chr-hertel added Server Issues & PRs related to the Server component enhancement Request for a new feature that's not currently supported labels Jul 5, 2026
chr-hertel
chr-hertel previously approved these changes Jul 5, 2026

@chr-hertel chr-hertel left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @soyuka - in general i'm totally in favor and this is a valuable addition.

one thought just came to mind tho:
what's keeping us from having that as default behavior of the registry - configurable - and that config switch exposed via Builder - so we'd in the end could help users control the slight behavior switch here

@soyuka soyuka force-pushed the feat/lazy-registry-loading-v2 branch from d393af6 to bfb0bb0 Compare July 8, 2026 07:40
@soyuka

soyuka commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Good call let me try this.

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.

Make deferred loading the default behavior of Registry itself: it takes a
loader and runs it on the first read (has*/get*), moving loading to
request time when the application is initialized. The load runs once; the
loaded flag is set only after a successful load, so a transient failure is
retried on the next read. A re-entrancy guard lets loaders that read the
registry during their own run (e.g. discovery's identity check) observe
the partial state instead of recursing. Registrations made before the
first read survive it.

Expose the switch on Builder via setLazyLoading() (default on) so callers
can opt back into eager loading at build time. In lazy mode capabilities
are advertised from the configured sources so the initialize handshake
does not force a load; in eager mode (or for a registry supplied via
setRegistry(), which is always loaded eagerly) they are read from the
loaded registry.
@soyuka soyuka force-pushed the feat/lazy-registry-loading-v2 branch from bfb0bb0 to ffae0f7 Compare July 8, 2026 11:07

@chr-hertel chr-hertel left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that entire loader <=> registry dependency feels more and more odd when looking at Builder and ->load($this), but that's def debt from before and off scope here

Thanks for this already @soyuka!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Request for a new feature that's not currently supported Server Issues & PRs related to the Server component

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants