PHP Has Been Stateless Since 1995. MCP Just Caught Up.

For as long as I have been writing PHP, and that is about fourteen years now, the shared-nothing request model has been the thing people bring up when they want to explain why PHP is not a serious language. Every request boots the world, does its work, and then throws everything away. No application state that outlives the request. Nothing carries over unless you deliberately persist it.

It turns out that was the right idea. The Model Context Protocol just spent eighteen months learning it the hard way.

The 2026-07-28 MCP specification shipped on July 28. The headline change is that MCP is now stateless at the protocol layer. The initialize and initialized handshake is gone. The Mcp-Session-Id header is gone. Every request now carries its own protocol version, client identity, and capabilities, which means any request can land on any server instance behind a plain round robin load balancer.

Most of the coverage has framed this as a Kubernetes story. Scale your pods, drop your Redis session store, deploy to the edge. All true, and all written for people running Go and TypeScript servers on infrastructure they control.

I want to talk about what it means for WordPress, because WordPress goes from being an awkward host for MCP to arguably the most natural one, and the reason is sitting right there in the official adapter’s source code.

What the old spec forced WordPress to do

Here is the problem in plain terms. Under the old spec, a remote MCP server had to hold a session. The client called initialize, the server minted a session ID, handed it back in a header, and every subsequent request carried that ID. The server was expected to remember what happened.

PHP has nowhere reliable to keep that in request-local memory. Once the request ends, its application state is gone, and the next request may be handled by a different PHP worker or a different server entirely. So you have to persist it somewhere, and in WordPress your options are the object cache, a transient, a custom table, or user meta.

The official WordPress/mcp-adapter plugin went with user meta. Sessions live in a single serialized array under the meta key mcp_adapter_sessions, keyed per user.

That decision cascades further than you would expect. Because it is one array holding many sessions, the adapter has to cap it, so there is a DEFAULT_MAX_SESSIONS of 32 and the oldest session gets evicted when you hit the ceiling. Because sessions go stale, there is a DEFAULT_INACTIVITY_TIMEOUT of one day and a garbage collection pass that runs on every write.

In the current release, v0.5.0, that is where it stops. create_session() reads the array, cleans it up, appends, and writes it back:

// v0.5.0
$sessions[ $session_id ] = array(
    'created_at'    => $now,
    'last_activity' => $now,
    'client_params' => $params,
);

update_user_meta( $user_id, self::SESSION_META_KEY, $sessions );

Read, modify, write, with nothing guarding the gap between the read and the write. Two concurrent connections for the same user now have a classic lost-update race.

The interesting part is what has happened since. If you look at trunk rather than the tag, SessionManager has grown from 340 lines to 459, and the additions are all marked @since n.e.x.t, meaning they have not shipped yet. The meta key is now resolved through session_meta_key() and scoped by blog ID for multisite, with a comment about cleaning up an orphaned mcp_adapter_sessions_0 row. And the bare write has been replaced with an optimistic retry loop:

private static function mutate_sessions( int $user_id, callable $mutation, ... ): bool {
    for ( $attempt = 0; $attempt < self::MAX_UPDATE_ATTEMPTS; ++$attempt ) {
        wp_cache_delete( $user_id, 'user_meta' );
        $previous_sessions = self::get_all_user_sessions( $user_id );
        $updated_sessions  = $mutation( $previous_sessions );

        if ( $updated_sessions === $previous_sessions ) {
            return true;
        }

        $updated = update_user_meta( $user_id, self::session_meta_key(), $updated_sessions, $previous_sessions );
        if ( false !== $updated ) {
            return true;
        }
    }
    // ... log failure after exhausting retries
}

Look at the first line inside that loop. wp_cache_delete( $user_id, 'user_meta' ), on every attempt, up to five times. That is deliberately blowing away the object cache for that user’s meta so the next read hits the database and sees whatever another request just wrote. On a VIP-style stack where the object cache is doing real work, you are punching a hole through it on the session write path.

And even after all that, the race is not fully closed. The docblock says so:

WordPress ignores an empty $prev_value. Concurrent first connections may therefore still overwrite each other, but subsequent writes retry when the previously read non-empty map has changed.

Watch the direction of travel here. The shipped version is a simple write with a race in it. The unshipped version is a retry loop, a cache invalidation, a compare and swap attempt, blog scoped keys, and cleanup for an orphaned multisite row, and it still cannot fully close the race. The workaround is not stable. It is accreting.

I want to be clear that this is not a knock on the adapter. Given a protocol that demands session state and a runtime that cannot hold it, that is a reasonable and carefully written implementation. Someone thought hard about it. The retry loop, the eviction policy, the multisite key scoping, all of it is competent work.

It is just work that should never have needed doing. Every line of that subsystem exists to paper over a mismatch between the protocol and the runtime. And as of 2026-07-28, the mismatch is gone.

What actually changed

The short version, for anyone who has not read the spec release:

No handshake, no session header. initialize and initialized are retired, along with Mcp-Session-Id. Each request is self describing, carrying its protocol version, client identity, and capabilities in _meta. There is a new optional server/discover RPC if a client wants capabilities up front, but nothing requires it.

Header based routing. Streamable HTTP requests must now include Mcp-Method and Mcp-Name headers. A request looks roughly like this:

POST /mcp HTTP/1.1
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: search

{"jsonrpc":"2.0","id":1,"method":"tools/call",
 "params":{"name":"search","arguments":{"q":"otters"},
 "_meta":{"io.modelcontextprotocol/clientInfo":{"name":"my-app","version":"1.0"}}}}

Your WAF, gateway, or rate limiter can now route and meter on those headers without parsing a JSON body.

Cacheable list results. tools/list, prompts/list, resources/list, and resources/read responses now carry ttlMs and cacheScope.

Multi Round-Trip Requests. Server initiated elicitation/create, sampling/createMessage, and roots/list are replaced. Instead of holding a stream open, the server returns resultType: "input_required" with what it needs, and the client retries the original call with answers attached in inputResponses.

Deprecations. Roots, Sampling, and Logging are deprecated with a twelve month minimum window. The legacy HTTP+SSE transport is deprecated too. Dynamic Client Registration is formally deprecated in favour of Client ID Metadata Documents.

Why this fits WordPress so well

Take that list and read it again as a WordPress developer.

The session store goes away entirely. No user meta array, no eviction policy, no retry loop, no cache busting on the write path. A tools/call becomes what every other WordPress request already is: boot, authenticate, do the thing, respond, die.

Cacheable list results are worth being precise about, because it is easy to read ttlMs as a server side caching feature and it is not. Those fields are hints to the MCP client, telling it how long it may hold onto your tool catalog before asking again. Whether you cache the work of assembling that catalog is a completely separate decision on a different layer.

The two do complement each other, though, and that is where WordPress has a real advantage over a bare Node server. A serious WordPress host already ships a distributed object cache. Building a tool list means walking registered abilities, running permission checks, and serialising schemas, and caching that in Redis is a problem this ecosystem solved a decade ago. Set a sensible ttlMs so clients stop asking, and cache the generation so the requests that do arrive are cheap.

Header based routing is genuinely useful on managed platforms. If you are on VIP or any host with an edge layer, you can write a rule against Mcp-Method: tools/call without body inspection, which makes routing, filtering, and rate limiting much easier to push outward.

What it does not do is replace your permission_callback. Mcp-Method and Mcp-Name are supplied by the client, which means they are supplied by whoever is making the request. They are fine for deciding where a request goes and how fast it may arrive. They are not an authorization signal. Unless your edge is independently authenticating the caller and enforcing equivalent policy, the real access check still belongs in the application, after WordPress has booted and knows who it is talking to.

And multiple app servers behind a load balancer stop being a problem. That was already true for the rest of WordPress. Now it is true for the MCP endpoint too.

State does not vanish, it just becomes visible

One thing worth being precise about, because it is easy to misread the announcement. Dropping protocol level sessions does not mean your application cannot have state. It means the transport stops hiding it for you.

The pattern the maintainers recommend is that a tool mints an explicit handle and the model passes it back as an argument on a later call. So instead of a hidden session ID in a header, you return something like an import job ID from your first tool and accept it as a parameter on the second.

For WordPress this is a better fit anyway, because you already have somewhere sensible to keep that: a post, a custom table row, an option, an Action Scheduler job. You were probably going to store it there regardless. The difference is that the handle is now part of the tool contract instead of an implementation detail wedged into the transport.

It also has a security implication people should think about, and I have not seen it discussed much. That handle travels through model context. It is visible to the model, and potentially to anything that can influence the model. Treat the handle as an opaque identifier, not proof of authorization, and make sure the permission check on the second call is just as strict as on the first.

Where the WordPress tooling actually stands right now

I checked, because I did not want to write this off a press release.

As of today, the latest release of WordPress/mcp-adapter is v0.5.0. Its McpVersionNegotiator declares three supported protocol versions, newest first: 2025-11-25, 2025-06-18, and 2024-11-05. 2026-07-28 is not among them, in the tag or in trunk. HttpSessionValidator still rejects requests with a missing Mcp-Session-Id, and the request handler still branches on initialize. There is no Mcp-Method, no Mcp-Name, no server/discover, no input_required, and no ttlMs anywhere in the codebase.

That is not a complaint. The spec is two weeks old and the AI team ships on its own cadence. But it means two things if you are running MCP on WordPress today.

First, if you are still on Automattic/wordpress-mcp, migrate. That repository was archived in January 2026 and WordPress/mcp-adapter is the maintained path.

Second, be precise about what breaks and when. A client speaking 2026-07-28 cannot complete a stateless flow against the adapter’s current HTTP path, because that path routes initialize and then demands Mcp-Session-Id on everything after it. There is no protocol level overlap between the two shapes.

That is not the same as saying your integration dies on client upgrade day. Clients can and generally do support several protocol generations, and the adapter still negotiates back to 2024-11-05, so a client willing to fall back will keep working. The risk is concentrated in clients that go stateless only, and in whatever the ecosystem decides is polite about fallbacks over the next year. Worth noting that the handshake and the session header were removed rather than deprecated, which is a different kind of change from the twelve month runway Roots, Sampling, and Logging get.

If you maintain a custom MCP server on WordPress rather than using the adapter, here is the short list:

  • Stop requiring initialize. Make every request self sufficient and read client info from _meta.
  • Delete your session storage, whatever you used, and replace any real cross-call state with explicit tool handles.
  • Read Mcp-Method and Mcp-Name and consider moving routing and rate limiting to the edge on those headers. Keep authorization in the application, since those headers come from the client.
  • Add ttlMs and cacheScope to your list responses so clients cache them, and separately cache the work of generating the catalog in your object cache.
  • Replace elicitation with MRTR if you were doing interactive confirmations.
  • Audit your OAuth integration, and be clear about which role you are in. Your MCP server is an OAuth 2.1 resource server, so the parts that are yours are RFC 9728 protected resource metadata, RFC 8707 audience validation, and never passing tokens through. Those are unchanged from 2025-06-18, so if you built against that revision, the core of your server is still correct.
  • If your implementation also acts as an OAuth client, and the mcp-wordpress-remote proxy is one, validate the iss parameter per RFC 9207 before redeeming an authorization code. If you depend on Dynamic Client Registration, start planning the move to Client ID Metadata Documents.

What is still awkward

I do not want to end this pretending PHP won outright, because it did not.

MRTR is a real improvement over holding a stream open, which PHP-FPM handles badly at best. But it turns one tool call into several HTTP round trips, each of which pays a full WordPress bootstrap. If your tool needs two confirmations, you are booting WordPress three times to answer one question. That is the cost of shared nothing and it has not gone anywhere.

Change notifications now travel over a subscriptions/listen stream that clients opt into. Nothing in the request path depends on one, which is the important part, but a long lived stream still ties up an FPM worker for its duration. If you want notifications on a busy site, that needs thought.

Tasks, now an extension rather than experimental core, are poll based. Polling is fine, and it is honestly the right model for PHP, but it means a scheduled runner and a place to keep task state. Action Scheduler is the obvious answer and it is not free.

None of that changes the direction. For the first time, the protocol and the runtime agree about what a request is. WordPress spent a year and a half being a slightly embarrassing place to put an MCP server. It is about to be one of the easiest.

The rest of the industry is busy tearing out Redis session stores and reconfiguring load balancers. WordPress developers get to delete a user meta key and move on.

Leave a Reply