Problem
The SDK currently dispatches only request-scoped events (RequestEvent, ResponseEvent, ErrorEvent, list-changed events). There is no hook that fires once per Server::run() invocation.
Consumers that need one-time setup/teardown per server boot must either:
- Duplicate the logic in every transport's entry point (HTTP controller, CLI command, custom transports), or
- Subscribe to
RequestEvent + matching ResponseEvent/ErrorEvent and deal with per-request overhead, fiber suspension, and every error path.
Proposal
Add two events in Mcp\Event:
ServerStartedEvent — dispatched at the top of Server::run($transport), before the first request is read. Exposes the server and transport.
ServerStoppedEvent — dispatched just before Server::run() returns, in a finally so it fires on clean exit, thrown exceptions, and transport close. Exposes the exit code and any captured throwable.
Use cases
- Identity / impersonation: switch the current user of a host framework (Drupal, Symfony Security) once when the server boots. This is our concrete motivation — without a lifecycle hook we either patch every transport or pay the cost of switching on every
RequestEvent.
- Metrics / observability: increment
mcp_server_started_total, start a run-duration timer, emit a "server up" log line including registered tool/prompt/resource counts.
- Resource management: warm caches, acquire leases, open long-lived connections at start; release them at stop.
Alternatives considered
- Use
RequestEvent as a pseudo-start hook — works but is per-request. Adds overhead to what is logically a one-shot, and requires pairing with ResponseEvent/ErrorEvent for cleanup. Fiber suspension (Protocol::handleRequest, early return on $fiber->isSuspended()) means the terminal event can be delayed or skipped from the subscriber's perspective.
- Subclassing
Server — not portable; transports and framework integrations instantiate Server directly via the Builder.
- Transport-level hooks — would require a change in every transport implementation (Stdio, StreamableHttp, any custom transport) rather than in one place in
Server::run().
Backward compatibility
Purely additive. Subscribers that don't care about the new events are unaffected. No public API changes to existing events or handlers.
Problem
The SDK currently dispatches only request-scoped events (
RequestEvent,ResponseEvent,ErrorEvent, list-changed events). There is no hook that fires once perServer::run()invocation.Consumers that need one-time setup/teardown per server boot must either:
RequestEvent+ matchingResponseEvent/ErrorEventand deal with per-request overhead, fiber suspension, and every error path.Proposal
Add two events in
Mcp\Event:ServerStartedEvent— dispatched at the top ofServer::run($transport), before the first request is read. Exposes the server and transport.ServerStoppedEvent— dispatched just beforeServer::run()returns, in afinallyso it fires on clean exit, thrown exceptions, and transport close. Exposes the exit code and any captured throwable.Use cases
RequestEvent.mcp_server_started_total, start a run-duration timer, emit a "server up" log line including registered tool/prompt/resource counts.Alternatives considered
RequestEventas a pseudo-start hook — works but is per-request. Adds overhead to what is logically a one-shot, and requires pairing withResponseEvent/ErrorEventfor cleanup. Fiber suspension (Protocol::handleRequest, early return on$fiber->isSuspended()) means the terminal event can be delayed or skipped from the subscriber's perspective.Server— not portable; transports and framework integrations instantiateServerdirectly via theBuilder.Server::run().Backward compatibility
Purely additive. Subscribers that don't care about the new events are unaffected. No public API changes to existing events or handlers.