Vist/ Blog/ Memory & MCP
Memory & MCP 9 min read

Going Stateless: Upgrading Vist's MCP Server to the 2026-07-28 Spec (and Opening Memory to Any Agent)

The biggest MCP revision since launch kills the handshake and the session. Here's how I shipped dual-version support in Vist's Rails server — and the agent-identity bug I found along the way.

Fair warning: this one's more technical than my usual posts — protocol internals, header negotiation, the works. If that's not your thing, no hard feelings, skip it and I'll see you in the next one.

The MCP spec revision landing on July 28 is the biggest one since the protocol launched, and it's not a polite one: the initialize/initialized handshake is gone, Mcp-Session-Id is gone, every request now has to stand on its own. If you run a remote MCP server, congratulations, you have a migration ahead of you.

I just shipped ours. Vist's MCP server now speaks both protocol versions at once — existing clients on 2025-03-26 see exactly the same behavior as before, while clients declaring 2026-07-28 skip the handshake entirely. Along the way I found a genuinely embarrassing bug in the agent memory layer, and stumbled — via one of the spec's new required headers — into a fix for something I'd been putting off for months: letting any agent framework, not just Claude, share the same persistent memory.

This post is the implementation story, for the handful of you who run or build MCP servers yourselves.

What the new spec actually changes

The headline is statelessness. Under the old protocol, a client called initialize, the server minted an Mcp-Session-Id, and every subsequent request carried it around. That session is transport-level hidden state, and it's why scaling an MCP server past one box used to mean sticky routing or a shared session store — joyless infrastructure work for what is, underneath it all, a chat protocol.

Under 2026-07-28:

  • No handshake. Protocol version, client info, and capabilities travel in _meta on every request. A new server/discover method replaces initialize for upfront capability fetching — same document, no session minted.
  • Three new required HTTP headers on Streamable HTTP requests: MCP-Protocol-Version, Mcp-Method, and Mcp-Name.
  • Cache hints. List results can carry ttlMs and cacheScope, modelled on HTTP Cache-Control, so clients stop re-fetching tool definitions that change once a quarter.
  • OAuth hardening. Clients must validate the iss parameter on authorization responses (RFC 9207, mix-up attack mitigation), and dynamic client registration gains application_type.

Any request can now land on any instance. Plain round-robin works, and the sticky-session code I never particularly enjoyed maintaining is one step closer to being deleted for good. If you've ever run a WebSocket fleet and then, blessedly, a plain HTTP one, you know the feeling I'm describing.

Dual-version support in one seam

I was not about to break every existing Claude Desktop and Claude Code connection on upgrade day, so the server negotiates per request. The controller computes the protocol version once — header first, then _meta.protocolVersion in the body, defaulting to legacy — and everything downstream branches on that single value:

def negotiate_protocol_version(request_body)
  header = request.headers["MCP-Protocol-Version"]
  return header if header.present?

  meta_version = request_body.dig("params", "_meta", "protocolVersion") ||
                 request_body.dig("_meta", "protocolVersion")
  return STATELESS_VERSION if meta_version == STATELESS_VERSION

  LEGACY_VERSION
end

One detail that matters: only an exact match on 2026-07-28 switches to the stateless path. Mere presence of _meta must never reroute a legacy client — plenty of old clients already send _meta for tracing, and I'd rather ship one boring conditional than debug a client that mysteriously lost its session.

On the stateless path, the entire session apparatus is bypassed: no session gate, no session creation on initialize, no Mcp-Session-Id response header. The session store still exists, untouched, serving legacy clients only. When the old protocol is eventually retired, I get to delete one class and a handful of controller branches, and I am already looking forward to it.

Two decisions I'll defend, unprompted:

I validate the new required headers leniently. The spec says Mcp-Method and Mcp-Name are required; I log a warning when they're missing and process the request anyway. Client rollout during an RC window is uneven, authentication doesn't depend on these headers, and a hard 400 would punish early adopters for their SDK's lag rather than for anything they did wrong. I'll revisit after GA. The one strict case: an explicitly unsupported version gets a -32600 listing what's actually supported.

Cache hints only on the new protocol. tools/list and prompts/list return ttlMs: 3600000, cacheScope: "public" (tool definitions are code, identical for every tenant); resources/list gets ttlMs: 60000, cacheScope: "private" because resource lists are per-account data. Legacy responses stay byte-identical — that's what "backward compatible" actually has to mean, and the pre-existing transport specs pass unmodified as proof, not just as a claim.

The whole flow, no handshake anywhere:

curl -X POST https://app.usevist.dev/mcp \
  -H "Authorization: Bearer $TOKEN" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Mcp-Method: tools/call" \
  -H "Mcp-Name: openclaw" \
  -d '{"jsonrpc":"2.0","id":"1","method":"tools/call",
       "params":{"name":"record_memory","arguments":{
         "title":"Reddit PKM lead",
         "content":"User frustrated with task switching",
         "memory_type":"learned_facts"}}}'

The bug we found on the way

Vist's memory layer stores an agent_id on every memory an AI agent writes, so agents can sync "their" memories back later. While auditing the code for this upgrade, I found that record_memory and sync_agent_memory derived that identity two different ways. One hashed account:api_key:model down to 16 characters; the other hashed just the first API key on the account down to 8. Two functions, one job, same repo, different answers every time. An agent's sync state never actually matched the identity it wrote under.

Nobody noticed, because sync doesn't filter memories by agent — the identity only fed bookkeeping that nobody was reading closely. But it's exactly the kind of latent inconsistency that turns into a real data bug the moment somebody (me, last week) builds a feature that does filter by agent. Both paths now go through one Memory::AgentIdentity service, because apparently I needed to write the same function twice before I noticed they disagreed. If you maintain an MCP server with any notion of caller identity, go check whether you've got two derivations of it lying around too. I did. I wrote both of them, in case that wasn't already clear.

Your agent's name is its identity

Here's where the spec upgrade and the memory layer meet. The new protocol requires clients to send Mcp-Name — their client name — on every request. That header turns out to be the cleanest possible answer to a question I'd been sitting on for months: how do third-party agent frameworks get a memory identity without a single line of configuration?

The rule is now: if your client sends Mcp-Name, that (sanitized) name is your agent identity. Every OpenClaw instance connected to an account writes and syncs as openclaw. Every Hermes instance as hermes. Memories written by the scout on your VPS are visible to the drafting agent on your laptop, because they're the same logical agent as far as memory is concerned — which is a much nicer sentence to write than it was to get working. Zero client-side setup — the header the spec already requires does all the work.

Before this change, third-party agents couldn't write memory at all: with no recognizable model headers they resolved to "unknown", and the wildcard trust config denies unknown writers. They now get a seeded limited-trust tier — writes allowed, no deletes, 200 writes/day, capped token budget. Genuinely anonymous clients stay write-denied.

query_memory also gained agent_id and project_name filters (both hitting existing indexes), so an agent can ask "what did the scout learn about lead-gen this week" as a scoped query instead of a hopeful, vibes-based semantic search.

The trust tradeoff, stated plainly

An automated reviewer on the PR asked the obvious uncomfortable question, so let's answer it here rather than pretend it wasn't asked: yes, a self-declared string now selects your trust tier. Any authenticated client on an account can send Mcp-Name: openclaw and get the limited tier instead of the write-denied wildcard.

Here's why that doesn't keep me up at night. Authentication runs before any of this — the name only ever selects a tier within an account you already hold credentials for, and rate limits are enforced per API key regardless of what name you claim. And, less obviously: the pre-existing model-header path was already more permissive. A client sending X-Model-ID: claude-opus-4 — also just a self-declared string, nothing stopping anyone — matches a fully trusted config, deletes included, no daily cap. The new named tier is capped and delete-free, and an explicit model header still wins over the client name when both are present. So the actual new exposure is a spoofer choosing to settle for less access than they could already claim, which is not a threat model I'm going to lose sleep over.

Verifiable client identity — binding names to keys at registration — is the real fix, and it belongs in a proper redesign of header-based trust, not a Friday-afternoon patch bolted onto a protocol migration. I'd rather ship the capped tier now and do that redesign deliberately later, which is a polite way of saying it's on the list.

The OAuth fine print

Two smaller compliance items, noted for fellow Rails/Doorkeeper people:

RFC 9207 iss. Doorkeeper 5.9 has no native support, so the authorizations controller overrides redirect_or_render to append iss to authorization redirects — success and error alike — and the RFC 8414 metadata now advertises authorization_response_iss_parameter_supported: true. It's a small override of a non-public Doorkeeper method, so it's pinned by request specs that will scream at me on the next gem upgrade. Good. That's what they're there for.

RFC 7591 application_type. Dynamic client registration accepts, validates (web/native), persists, and echoes it. One additive column, nothing dramatic.

Total damage for full spec support: a constants file, a controller seam, a handler parameter, two small OAuth patches, and one new service that fixed a bug that had been quietly wrong for a while. The stateless model is genuinely simpler than what it replaces — most of this diff is the dual-version part, and that's the part I get to delete later, assuming legacy clients ever actually die off, which is its own small act of faith.

If you're connecting an agent to Vist — Claude, OpenClaw, Hermes, or something you wrote yourself — the endpoint is https://app.usevist.dev/mcp, and as of this week, your agent's name is all the identity it needs.

Memory & MCP