Chapter 2 of 7 · 15 min read
The initialized notification
On this page
Between a transport being open and a session being usable there is a handshake, and the
part of it that goes missing is the part with no return value. initialize is an ordinary
JSON-RPC request — JSON-RPC being the message envelope MCP travels in, where every request
carries an id and earns exactly one response bearing that same id — and its response is
an ordinary result. The third message is a notification: no id, no reply, nothing to await.
That asymmetry is what makes it easy to lose, and losing it produces a failure that reads as something else entirely. "Method not found" for a method that plainly exists is the signature of a request that arrived while the session was in a state where no methods existed yet, and every part of the diagnosis depends on knowing that such a state is a defined one with defined rules.
It is also a fault with a timing signature, which is rare enough to be worth exploiting. A connection has three states — open but not negotiated, negotiating, and ready — and only the middle one rejects otherwise-valid requests. Anything that goes wrong there therefore happens within a few hundred milliseconds of the connection being opened and never afterwards, so plotting failures against connection age separates this class from everything else in one chart. A defect whose incidence collapses to the first second of every connection is an initialization-state defect; a defect spread evenly across the life of a session is not.
Three messages, and the third one has no id
The opening exchange is initialize, the server's response, and then a notification from
the client saying it has read the response and considers the session established. Written
out as they appear on the wire, with the framing stripped:
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18",
"capabilities":{"roots":{"listChanged":true},"sampling":{}},
"clientInfo":{"name":"acme-desktop","version":"3.4.1"}}}
{"jsonrpc":"2.0","id":1,"result":{
"protocolVersion":"2025-06-18",
"capabilities":{"tools":{"listChanged":true},"resources":{"subscribe":true}},
"serverInfo":{"name":"ticketing","version":"0.9.2"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}The third message has no id, which is the entire reason it is easy to lose. Nothing is
returned for it and nothing awaits it, and in a client written around request/response
promises there is no natural place for it to live. A developer reading their own code sees
await this.request("initialize", params) resolve with a perfectly good result and
reasonably concludes the connection is up. The server disagrees, because from its side the
response it sent was an offer of terms and the notification is the client's acknowledgement
that it read them and is proceeding on those terms.
Between the second and third arrows the session is in a defined state with defined rules,
and those rules are why the error text is misleading. A conforming server sitting in
initialization does not have a tool registry to consult, so it does not answer "I looked
and there is no create_ticket"; it answers with the closest error code available for a
method it will not dispatch, which in most implementations is -32601. The message you get
names the tool, and the fault has nothing to do with the tool.
| Message arriving | Before initialize | Inside initialization | After initialized |
|---|---|---|---|
initialize | processed | invalid request | invalid request |
ping | allowed | allowed | allowed |
logging/setLevel | rejected | allowed by most servers | processed |
notifications/initialized | ignored | completes the handshake | ignored |
tools/list, tools/call | rejected | rejected | processed |
resources/read, prompts/get | rejected | rejected | processed |
ping and logging/setLevel are the two exceptions worth knowing, because they are what a
client may legitimately use before the session is fully open — a liveness check and a
request to raise the server's log level so that the rest of the handshake is observable.
Everything else in that table is a request the server is entitled to refuse, and refusing
is the correct behaviour rather than a strictness setting someone chose.
Why 98% of the calls work
A client that never sent the notification at all would fail 100% of the time, so a rate like 98% succeeding — with the failures clustered in the first second of each connection — is telling you that the client does send it and that ordering is not guaranteed. That combination points at the transport.
That inference is short enough to write out, and it is the kind worth being able to construct from scratch, because a success rate is usually treated as a severity number rather than as evidence about a mechanism. Start from the two hypotheses that fit the error. Either the client never sends the notification, or it sends it and something reorders it. Under the first hypothesis, every request on every connection arrives while the session is still initializing, so the failure rate is total — there is no path by which a call could succeed, because the state that permits calls is never entered. Under the second, the notification usually wins the race and occasionally does not, so most calls succeed and a minority fail. An observed rate anywhere below total therefore eliminates the first hypothesis outright, and it does so before anyone reads a line of the client's source. The useful habit here is not the conclusion but the shape: a defect that would be deterministic if a step were missing, observed intermittently, means the step is present and something about its ordering is not.
Over stdio, the transport in which the host launches the server as a child process and the
two exchange newline-delimited JSON on its standard input and output, the client's messages
travel down one pipe in the order they were written, so a client that writes initialized
before tools/call will have them arrive that way every time. Over streamable HTTP, the
transport used for servers reached across a network, each client-to-server message is its
own POST, and two POSTs issued back to back from an event loop can be dispatched on
different connections from the pool, cross the network independently and be handed to the
server's request handlers in either order.
The difference between those two transports is a property of the channel rather than of either implementation, and it is worth stating as a chain because it predicts every other ordering bug on either one. A pipe is a single byte stream with one writer, so bytes leave in the order they were written and arrive in the order they left; there is nowhere for a later byte to overtake an earlier one, because there is only one path. Streamable HTTP has no such object. Each client-to-server message is an independent request, connections are drawn from a pool so two requests may travel on two different sockets, each socket has its own buffering and its own route through whatever sits in front of the server, and the server accepts and dispatches them concurrently. Every one of those is a place where the second message can get ahead of the first. Nothing is broken in that description — HTTP never promised an ordering across separate requests, and a client relying on one is relying on a guarantee it was never given. Which is also why the repair belongs in the client: the ordering it needs has to be manufactured by the client waiting, because no layer beneath it is going to supply one.
The client code is one line:
await this.request("initialize", params);
void this.notify("notifications/initialized"); // fire and forget
const tools = await this.request("tools/list"); // POSTed immediately afterThe void is the bug. The notification is dispatched without being awaited, so the
tools/list POST leaves at essentially the same moment, and the server sees whichever one
its socket accept loop got to first. Roughly one time in fifty, on a loaded machine, that
is the request. The fix on the client side is one keyword — awaiting the notification's
write before issuing any request — and the reason to insist on it rather than working
around it on the server is that the same reordering will produce a different symptom the
next time something depends on initialization state.
A retry policy on the client makes this much harder to find, and most hosts have one. If
the failing tools/call is retried after 250 ms, the notification has long since been
processed and the second attempt succeeds, so the user sees a slightly slow first action
rather than an error and nobody files anything. The server's own log is then the only place
the failure exists at all, which is how a defect like this survives for months in a product
that is otherwise well monitored. It also inverts the usual instinct about retries: here
the retry is not covering a transient network fault, it is covering a protocol ordering
error that will keep producing new symptoms, and every successful second attempt removes
the evidence that would have located it.
The reason a retry works here at all is worth being precise about, since it explains both why the defect hides and why the retry is nonetheless the wrong remedy. Retrying repairs a failure whose cause is transient — something that was true when the request was made and is no longer true when it is repeated. The state check is exactly that: the session was initializing, the notification landed in the meantime, and the session is now ready with both sides' declarations recorded against it, so the second attempt meets a different world. Retrying works, in the narrow sense that the call succeeds. What it does not do is remove the condition, because the condition is a client that issues requests before it has finished the handshake, and that client will do it again on the next connection and against the next server. So a retry converts a reproducible protocol error into a latency spike, which is the least investigable form a defect can take, and it does so on every connection for as long as the code lives.
The response is not the end of the handshake
The reply to expect from a client team is that they send initialize, then list, then
call, and that the initialize response came back fine. Both halves are true and the
sequence is still invalid, because the response coming back fine is a statement about the
second message, and the third message is the client's obligation. The handshake is not
complete when the server has spoken; it is complete when the client has confirmed it
accepted what the server said. That confirmation exists for a specific reason: the server's
response is a counter-offer on protocol version and a declaration of capabilities, and a
client that has not yet read it might still disconnect. Until the notification arrives, the
server does not know whether it is in a session or in a negotiation that is about to be
abandoned.
It also helps to be clear about what the notification is not, since three neighbouring
ideas get substituted for it. It is not a health check — ping exists for that and is
legal at any point, including before initialize. It is not a signal that the server is
ready, because the direction is wrong: it travels client to server, and the server
announced its readiness in the response. And it is not a response, which is why a client
built entirely from await request(...) calls has no slot for it and why SDK-based clients
never hit this problem — the SDK's connect() sends all three messages and only resolves
after the third has been written. The hosts that get this wrong are almost always the ones
that implemented the protocol directly, and the ones most likely to implement it directly
are the ones embedding a client into an existing service that already has its own JSON-RPC
plumbing.
There is a stricter reading available on the server side, and it is worth knowing where the line sits. The specification says the server should not respond to requests other than pings and logging before initialization is complete, and the client should not send them. "Should" rather than "must" means a lenient server exists and is technically conformant. That is precisely what makes the lenient path attractive.
Two server-side repairs, and what each costs
The tempting repair is server-side and it works immediately. There are two shapes of it. One is to notice that the tool registry is populated during startup and to move that population earlier, so the transport only starts accepting messages once every tool is registered — this reads as a plain race fix and it is where most people look first. The other is to relax the state check and let requests through while the session is still initializing, on the grounds that the server is ready anyway.
Both make the errors disappear from the log, and the first one does not even touch the state machine, so it feels safe. Neither addresses what happened, because the registry was never the problem: the tool was registered at import time, long before any connection existed, and the error came from the session state check, not from a lookup miss. Reordering startup on a server whose registry is already populated changes nothing at all, which means someone will ship it, see the number not move, and go looking for a deeper server bug that is not there.
Why that repair is so tempting is worth naming, because it is a mistake about which of two similar-looking things produced the error rather than a lapse of care. Both a registry lookup and a session-state check can answer "method not found", and from outside the server they are indistinguishable — same code, same string, same position in the trace. The registry explanation also fits the timing evidence perfectly: failures clustered in the first second of a connection is exactly what you would expect from something still warming up. What rules it out is a fact that has nothing to do with the failure at all, which is when registration happens. The tools are registered by a decorator at import time, which is before the process is listening, which is before any connection exists, which is before any of these requests were sent. A registry that was already complete before the first byte arrived cannot have been incomplete when the request was refused. The general move is to look for a fact that dates the two candidate causes relative to each other, and it settles the question without needing to reproduce anything.
Relaxing the state check does move the number, and it buys the following. The client team
is told their sequence was correct, so the void stays in their code and ships to every
other server they connect to, half of which are strict. Anything on your side that
legitimately depends on initialization state now runs against an unpopulated session: the
negotiated protocol version is not yet fixed, so a request that should be answered in the
2024-11-05 shape may be answered in the 2025-06-18 one; the client's declared capabilities
are not yet trusted, so a server-initiated sampling/createMessage triggered by that early
tool call has no way to know whether sampling is available; and any per-session context the
server builds from clientInfo — a log correlation id, a tenant lookup, a feature flag set
— is either absent or half-built. The cost of the lenient path is a class of bug that only
appears when initialization state starts mattering, on a client whose ordering nobody will
revisit because the errors stopped a year ago.
async def dispatch(self, msg: dict) -> dict | None:
method = msg.get("method")
if self.state is SessionState.READY or method in ("ping", "logging/setLevel"):
return await self.handlers[method](msg)
if method == "initialize" and self.state is SessionState.CONNECTED:
self.state = SessionState.INITIALIZING
return self.build_initialize_result(msg)
if method == "notifications/initialized" and self.state is SessionState.INITIALIZING:
self.state = SessionState.READY
log.info("session ready", extra={"conn": self.conn_id, "ms": self.age_ms()})
return None
log.warning(
"pre-init request rejected",
extra={"conn": self.conn_id, "method": method, "state": self.state.name,
"ms": self.age_ms()},
)
return jsonrpc_error(msg.get("id"), -32601, f"Method not found: {method}")The one change worth making on the server is in that last log line rather than in the behaviour. Recording the session state and the connection age alongside the rejected method turns "Method not found: create_ticket" into "rejected in state INITIALIZING at 4 ms", which is a sentence the client team cannot argue with and which never needed a trace capture to produce.
Three messages, and the one that is easiest to skip is the one that ends the negotiation. Everything about the failure it produces is misdirection: the error code says the method is unknown, the message names a tool that exists, the timing looks like a warm-up problem, and the retry that hides it makes the whole thing look like an occasional slow start. The only reliable way through it is to stop reading the error text and start reading the position of the notification in the log.
What those three messages agreed on is a separate question, and it is the one that decides
what the session can actually do. The initialize request carried a protocol version and a
set of client capabilities; the response carried the server's version and its own
capabilities; and those two objects, and nothing else, define what this session is
permitted to do for as long as it lasts.
Architecture — read the current version at https://daviddominguez.dev/learn/en/architecture/the-initialized-notification