147 2026-08-28 · 11 MIN · LONG-FORM

Sessions Left the Protocol. Put the Handle in the Argument.

platform_info mints dps_…; the model threads it. Go SDK v1.7.0, MCP 2026-07-28.

Diagram · 147
flowchart LR
  INFO["platform_info"] -->|"mints dps_…"| ROW[("sessions row<br/>TTL 8h")]
  AGENT["model"] -->|"session_id argument"| GATE{{"SESSION_REQUIRED<br/>or SESSION_EXPIRED"}}
  ROW --> GATE
  GATE --> TOOL["gated tool"]

This is MCP by Design, an ongoing development log of mcp-data-platform, a Go server I run in front of a warehouse, a catalog, object storage, APIs, other MCP servers, and a knowledge base, also hosted as Plexara. The Model Context Protocol (MCP) is how a language-model agent talks to a server like that: it lists the tools, it calls them, the results come back over JSON-RPC. The notes are the engineering that process grew in order to survive real agents, written as the code shipped, not as a tour of the spec.

This is the eighth post in MCP by Design. The earlier notes covered Go, composition, steering, knowledge, testing, Starlark, and Hive. This one is the handle. It is grounded in the open-source txn2/mcp-data-platform, also available hosted as Plexara.

§The Header Went Away

The 2026-07-28 MCP spec took the session out of the protocol. Mcp-Session-Id is gone, and so is initialize. Any replica can take the next POST. Application state did not vanish with the header: enrichment dedup, the search-first gate, and provenance still need a row a replica can read, and a value the model can see. I mint that value from platform_info and refuse gated calls that never established one. This post is that pattern as it sits in pkg/session.

This piece does not cover OAuth, personas, rewriting the Go SDK, or a walkthrough of the SEP text. It also does not scale the whole platform out. It covers the handle: prefix, entropy, store, SESSION_REQUIRED, and how handshake-era clients still coexist with stateless ones.

SEP-2575 removed the initialize / initialized handshake. Protocol version, client info, and client capabilities that used to be exchanged once at connect time now travel in _meta on every request. A new server/discover method is how a client asks what the server can do, and it is optional. SEP-2567 removed the Mcp-Session-Id header and the protocol-level session that came with it. The 2026-07-28 spec is explicit about the replacement: mint an explicit handle from a tool and have the model pass it back as an argument. The model can see that string. It could not see a header the transport hid from it.

The official Go SDK shipped v1.7.0 on the same day, and mcp-data-platform is on that version. Streamable HTTP accepts 2026-07-28 only when StreamableHTTPOptions.Stateless is true. Leave it false and clients negotiate down to 2025-11-25. Almost every migrate guide that followed is Python. Operators of Go MCP servers still need the rest of the sentence: what the handle looks like, where it is stored, what the refusal says, and what happens when a handshake-era client is still sending the header the spec deleted.

I wrote the handshake and the Streamable HTTP transport when the session id was still on the wire, including the lifecycle that 2025-11-25 required before other methods were legal. The capstone still opens that way. Those posts are the JSON you will see on older clients. This one is what replaced the header on the server that has to keep serving both.

§Sticky, Redis, or a Handle

The alternatives are real, and most of them were correct last year.

Sticky load-balancer sessions keep a client on the replica that ran initialize. They work until that replica dies mid-conversation, and they are a property of the load balancer, not of the protocol. Shared Redis was the handshake-era default for a fleet: GitHub’s Go MCP server kept a session store there, wrote on initialize, and read on every call. On 23 July 2026 they removed it. Database writes on initialize are gone, and so are the reads on every call, because nothing in that server needed a session once the protocol stopped providing one. That is the right move if nothing is session-scoped.

Fully stateless, with no application handle either, is the same bet. It only holds if enrichment never dedups per conversation, the search-first gate has nothing to remember, and provenance has no session to bind a dashboard to. A server that is a thin wrapper over one API can take that bet. This one cannot.

FastMCP is the Python answer, and it is a clean one. UserSession injects a per-user bucket that never appears in the tool schema; identity selects it. SessionId is the other shape: the identifier is an argument, create_session mints it, and a shared store (in-memory, Redis, or a wrapper with a TTL) keeps the bucket. AAIF published the migrate notes that walk a handshake-era Python server onto that model. The Go version of “mint a handle, refuse calls that lack a session, persist it where a replica can read it” was the gap.

This platform uses Postgres for handshake-era transport sessions and explicit dps_ handles for the new spec, because workflow gating and provenance still need a row. The door stays open: sessions.handles.enabled: false keeps the old transport-session behavior, for a deployment that has nothing session-scoped to protect.

§Stateless Plus a Database

sessions.store: database is not a contradiction of streamable.stateless: true. It is the reason for it.

By default the Go SDK’s Streamable HTTP transport keeps sessions in process memory. A restart invalidates every Mcp-Session-Id the clients still hold. Handshake-era clients (Claude Desktop is the one that forced the revive path) do not update their stored session id from response headers, so minting a new id on expiry would loop. The database store is the replacement for that in-process map. Selecting it forces Server.Streamable.Stateless so the SDK skips its own session table. AwareHandler in pkg/session/handler.go then owns the lifecycle the SDK no longer will: mint a 128-bit hex id, validate ownership against a hash of the bearer token, revive an expired row under the same id when credentials are still present, and terminate on DELETE.

A stateless SDK handler in v1.7.0 serves POST only. DELETE with no inner session map would be 405 Method Not Allowed for a termination that had already succeeded, so the wrapper answers it here: 204 No Content once the row is gone, 400 when the Mcp-Session-Id header is absent. A GET with Accept: text/event-stream is the same story. The SDK in stateless mode 405s GET, because it does not offer an SSE stream of its own. The platform intercepts that GET, validates the session against the store, and holds a stream whose events fan out through Postgres LISTEN / NOTIFY on mcp_notifications, so a tool-list change on one replica reaches SSE clients on the others.

database:
  dsn: "${DATABASE_URL}"

sessions:
  store: database
  ttl: 30m
  cleanup_interval: 1m
  handles:
    enabled: true
    ttl: 8h
    require: true

After that config, any replica can serve handshake-era traffic, because it looks the header up in Postgres rather than in its own memory. Clients see no change. The header still works. What now exists is two lifetimes on one table. Create is an upsert on id, so revival of an expired row does not trip a unique constraint. Transport sessions expire in 30 minutes and revive under the same id. Explicit handles expire in 8 hours and refresh on use, throttled to the second half of that TTL so an active conversation is not a full-row write on every tool call. The sessions table is the same either way. The prefix is how an audit row tells them apart.

Caution: AwareHandler still binds a transport session to a hash of the token that created it. A different token on the same Mcp-Session-Id is HTTP 403, session ownership mismatch. That is the session-hijacking mitigation the spec required when the id lived in a header: the session is not authentication, and a guessed id without the token does not impersonate anyone. Anonymous sessions skip the check, because there is no token to hash.

flowchart TB
    subgraph handshake["handshake-era client"]
      H["Mcp-Session-Id header<br/>bare hex"]
    end
    subgraph modern["2026-07-28 client"]
      P["platform_info"]
      A["session_id argument<br/>dps_ plus 128-bit hex"]
      P --> A
    end
    subgraph store["Postgres sessions table"]
      T["transport row"]
      D["dps_ row<br/>minted_by: platform_info"]
    end
    H --> T
    A --> D

§platform_info Is Never Gated

The spec’s “mint an explicit handle from a tool” is one tool on this server: platform_info. It is the init tool, it is never gated, and it is the recovery path from SESSION_EXPIRED. Validating a stale handle on that call would refuse the one call that mints a fresh one, and the agent would be stuck.

MintHandle in pkg/session/handles.go is the whole mint:

func MintHandle(ctx context.Context, store Store, userID, persona string, ttl time.Duration) (*Session, error) {
	handle, err := GenerateHandle()
	if err != nil {
		return nil, err
	}
	now := time.Now()
	sess := &Session{
		ID:           handle,
		UserID:       userID,
		CreatedAt:    now,
		LastActiveAt: now,
		ExpiresAt:    now.Add(ttl),
		State: map[string]any{
			StateKeyMintedBy: MintedByPlatformInfo,
			StateKeyPersona:  persona,
		},
	}
	if err := store.Create(ctx, sess); err != nil {
		return nil, fmt.Errorf("persisting session handle: %w", err)
	}
	return sess, nil
}

GenerateHandle is prefix plus 128 bits of cryptographically random hex, the same entropy helper that mints portal and script ids. The prefix is how a log line and an audit row stay sortable without a join:

const (
	HandlePrefix         = "dps_"
	PortalSessionPrefix  = "dpp_"
	ScriptSessionPrefix  = "dpx_"
	MintedByPlatformInfo = "platform_info"
)

dps_ is what platform_info returns as session_id. dpp_ is a portal “Try It” run: a fresh in-memory MCP session per HTTP request, no transport id, nothing persisted, isolated so a replay does not pollute the operator’s live agent session. dpx_ is a managed-script run, same shape, different prefix, so an audit row naming a script is not mistaken for a portal click. IsHandle matches only dps_. A portal id is never accepted as a platform_info handle, and LatestHandleForUser filters id LIKE 'dps_%' so a churning transport hex is never adopted as one.

tools/list advertises session_id on every tool except platform_info, which mints it and takes none. The middleware strips the argument before the toolkit handler runs, so mcp-trino and a gateway-proxied upstream never see a platform-injected field. A proxied tool that legitimately defines its own session_id is left alone: only a value with the dps_ prefix is taken.

The resolver runs after authentication, because the same-identity check needs UserID. A presented handle that is unknown, expired, or owned by a different user is refused identically, as SESSION_EXPIRED, so a caller cannot probe whether a handle exists. A missing handle on a gated tool is the other refusal:

msg := fmt.Sprintf(
	"SESSION_REQUIRED: Call %s first. It returns a session_id you must pass as the "+
		"session_id argument on every subsequent tool call.",
	initTool,
)

That is a tool error (session_required), not an HTTP 400. HTTP 400 is the handshake-era DELETE with no header. Mixing those two up is how a migrate guide sends an agent looking at status codes for a refusal that arrived in the tool result.

require: true does not mean every call must thread the argument. It means a gated caller must have an established session. A call that carries a valid handle uses it. A call that carries none adopts the caller’s most-recently-active dps_ row, resolved from their authenticated identity, so an MCP App’s sandboxed iframe (which cannot thread the handle on its own calls) is scoped rather than refused. Only a caller with no session at all sees SESSION_REQUIRED, which is what keeps platform_info structurally required for a genuinely fresh agent. REST, the admin tool runner, and a script run are exempt by source, not by a hole in the gate: they drive a fresh in-memory session per request and have no model to handshake with. An unset source stays gated.

Note: a session-store outage fails open. The authenticator is the security boundary; the handle is a scoping key. A transient Postgres blip that locked out every handle-bearing caller would be worse than a few unscoped calls that self-correct when the store recovers. The handle is still adopted best-effort so audit and provenance stay coherent.

§What Now Exists

Call platform_info with empty arguments. The first text content block is JSON. It carries session_id (dps_ plus 32 hex characters) and session_expires_at in RFC3339, and the agent instructions open with a line telling the model to pass that value on every subsequent tool call. A row exists in sessions with state.minted_by = platform_info, owned by the authenticated user, expiring eight hours from mint.

Call trino_query next, as a fresh agent, with no session_id and no prior handle to adopt. The result is SESSION_REQUIRED. Call it with a handle minted for someone else, or one that has expired, and the result is SESSION_EXPIRED. Call platform_info again, thread the new value, and the query runs. The toolkit never saw session_id. The audit row did.

The search-first gate, enrichment dedup, and provenance capture then key on that handle, which is the whole reason steering could put a session gate in the middleware stack instead of in a tool description the model would quote and ignore. A conversation that never called platform_info has nothing to key on, and the refusal is how it finds that out.

§What You Take

The protocol is stateless on purpose. Application state is not. GitHub dropped Redis because their server had nothing left to store. FastMCP stores a bucket per user, or an explicit SessionId, in whatever backend you wrap. This platform mints dps_ from platform_info, refuses a gated call that has no session, and persists the row in the Postgres the platform already runs, next to the handshake-era transport sessions that AwareHandler still serves for clients that send Mcp-Session-Id.

The prefixes keep those populations apart: dps_ for the model, dpp_ for a portal run, dpx_ for a script run, bare hex for the header. Entropy is 128 bits. TTL is eight hours, refreshed on use. Unknown, expired, and cross-identity handles are one error. The init tool is never gated.

That is pkg/session/handles.go, pkg/session/handler.go, pkg/session/postgres/store.go, and the resolver in pkg/middleware/mcp_session_handle.go. The rest of this series assumes a session the model can name.

← back to all notes