Skip to content
Harness Engineering2026-09-1010 min read

Stateless MCP Application State: Where Identity Really Lives

Keep MCP job state durable while connections change. Check authenticated identity and job permissions on each protected request before resuming work.

Key takeaways

  • A stateless transport does not preserve business progress for you; the application must persist job state and reconcile interrupted external actions.
  • A job identifier is not a credential; anyone who learns an order number should still be refused if they are not the recorded owner of that order.
  • Durable job state needs four things at minimum: an owner, a status, a record of completed external actions, and a place to check both on every reconnect.
  • Validate the current caller and job permission on each protected request, including requests after reconnect; connection reuse does not preserve authority.
  • A local dictionary that enforces caller-versus-owner checks is a teaching model for the authorization logic, not a substitute for a database with real concurrency guarantees.

Rod Rivera

Author

Stateless MCP Application State: Where Identity Really Lives

Rod's note — read with a pencil; the margins are for you.

When an MCP connection drops, the job and the caller must survive without it

If your MCP server runs stateless, keep two things outside the connection: the job's progress, in durable storage, and the decision about who may touch that job, made again on every protected request. A reconnecting client then resumes from the stored record, and a stranger who learns the job ID is still refused.

Who this is for: engineers who already run or integrate an MCP server over HTTP and are deciding where session, identity and job state belong. You should be comfortable reading Python and know what an access token is. The example needs only the Python standard library; no MCP SDK, API key or network access.

What you will have at the end: a short authorization check you can run locally, three calls with their expected output (the owner gets pending, another caller gets denied, a missing caller gets its own denial), a table of reconnection cases to test against your real adapter, and a four-step checklist for your own server.

Here is the situation the rest of the article works through. A hypothetical client starts a supplier-order job through an MCP server, then loses its connection. The client reconnects a minute later. The application needs to recover the job's progress and decide whether this caller can still access it. Neither decision should depend on remembering the old network connection.

LangChain's article on stateless MCP and elicitation discusses support for stateless operation. That is a transport and integration choice, not a universal claim that every MCP server has no session state. Here we examine an application using stateless requests and a separate durable job store.

The separation lets a new connection reach the same business record, but it does not make recovery automatic. Persist the operation identity, status and uncertain external outcomes explicitly. A dropped connection may leave a supplier call unresolved even when the job record itself remains available.

Four identities that get flattened into one

Before working the example, it helps to name four things precisely, because sloppy language here is where the misconception takes root.

A request is one message on the wire. A connection carries traffic; a protocol session, when used, is a separate logical context that need not have the same lifetime as one connection. A caller is the authenticated party making requests, established by the authentication layer; its credentials can expire or be revoked independently of a connection. A job is the durable unit of business work, like "process supplier order 17," which has its own lifecycle independent of any connection or even any particular caller session.

The MCP authorization specification defines authorization for HTTP-based transports, including resource-bound access tokens. It does not define your application's job ownership rules. Keep token validation and the permission to read or mutate a particular job as separate checks. The HTTP authorization flow also should not be assumed to apply unchanged to a local stdio integration.

The worked case: order-17 across two connections

Picture a durable job record for a supplier order:

FieldValue
job_idorder-17
ownerAna
statuspending
completed_actions[]

Ana opens transport session c1, starts the job, and the connection drops before it finishes. She reconnects on a new transport session, c2. The job record above lives in application storage, not in either transport session, so it is still sitting there with status: pending when c2 opens.

For this example, every protected request carries authenticated caller context. Validate that context on requests from c2, just as on requests from c1, then check whether the caller may access order-17. We use owner-only access to make the example small. A shared team application could instead use explicit memberships, roles or delegated permissions; knowing the job ID is insufficient in either design. Now bring in a second caller, Bo. Suppose Bo, through a shared link or a guessed identifier, sends a request referencing order-17 on his own connection. If the server's logic only checks "does this job ID exist," Bo gets the state of Ana's order. If the logic checks "is the authenticated caller the recorded owner," Bo is refused, and refused before any job payload is returned, not after.

The application may read ownership metadata internally to make the decision. The boundary is that it returns no protected job payload to Bo before authorization succeeds. Where job existence is sensitive, use consistent unauthorized/not-found responses as well.

Each protected request checks caller and job permission

A teaching sketch before any protocol code

Here is a bounded, in-memory sketch that isolates the authorization logic, deliberately before any MCP transport enters the picture. This is standard-library Python, runs in a single process, and proves only what happens inside that process.

python
jobs = {
    "order-17": {"owner": "Ana", "status": "pending"}
}

def resume_job(caller, job_id):
    # caller must come from trusted authentication middleware, not request JSON.
    if caller is None:
        return "denied: no caller context"
    job = jobs.get(job_id)
    if job is None or caller != job["owner"]:
        return "denied"
    return job["status"]

print(resume_job("Ana", "order-17"))   # expected: pending
print(resume_job("Bo", "order-17"))    # expected: denied
print(resume_job(None, "order-17"))    # expected: denied: no caller context

Predict the three outputs before running this mentally: Ana gets pending, Bo gets denied, and a missing caller gets its own explicit denial rather than silently falling through to an allow. Notice what happens if you rename the transport connection variable anywhere in a real system, from c1 to c2 say, and change nothing else: the jobs dictionary is untouched, so the answers do not change. The transport identifier is not a key in this authorization decision. The caller strings are stand-ins for an identity already authenticated elsewhere; accepting a user-supplied string such as "Ana" would defeat the check.

This dictionary is not a database. It has no concurrent-write protection, no durability across a process restart, and no query language. Treat it as a model of the authorization decision, not as a production state store. A real system needs a database or equivalent durable store precisely because an in-memory counter or dictionary proves nothing about what happens when two processes touch the same job at once, or when the process restarts.

A job ID is not a credential

If your resumption logic accepts any caller who supplies a valid job ID, you have built a bearer-token system out of an identifier that was never designed to be secret. Job IDs often appear in logs, URLs, and error messages. Ownership must be checked against authenticated caller identity, not against knowledge of the ID.

Test reconnection without widening access

Use a table of expected outcomes before implementing the transport adapter. These controls concern the owner-only policy above, not every possible collaboration model.

Request contextRequested jobExpected result
Ana on c1Ana's orderpending
Ana on c2Same orderpending
Bo on either connectionAna's orderdenied
No authenticated callerAny jobDenied before returning job data
Authenticated callerUnknown or inaccessible jobConsistent denial
Expired credentials on reused connectionAny protected jobAuthentication rejects the request

The local helper can test the first five application decisions using supplied caller context. It cannot test token expiry, signatures or MCP request handling. Exercise those through the actual adapter with controlled credentials, and verify that the authentication result, not a field from request JSON, supplies caller.

The second diagram follows a different concern: what survives reconnection. A new connection can read the same authorized job record, including an uncertain external action. It must not reset that action to pending merely because the transport is new.

Requests on two connections resolve to the same authorized durable job and operation record, including any unresolved external effect.

Test a dropped response after an external effect separately. Persisting only completed actions leaves a gap when the receiver commits but the local confirmation is lost. Record an attempted operation as uncertain before sending, use the same operation ID across retries, and reconcile against the receiver's contract. A reconnect by itself provides no evidence that resending is safe.

Separate the four identities in your own system

Write down, for one real job type you handle, what the request, the connection, the caller, and the job actually are in your code. If two of these are the same variable, that is the seam to fix first.

Put ownership and status in durable storage

Move the job record out of any in-memory session object and into a store that survives a process restart. Choose a store with explicit atomic-write, recovery and concurrency behavior. Writing a dictionary to a file does not by itself establish those guarantees.

Authenticate and authorize each protected request

Do not let "the job ID matched" stand in for "the caller is authorized." Obtain caller identity from the verified request context, then check job-specific permission before returning protected data or performing an action.

Reconcile recorded external operations before retrying

Before resending, inspect the operation record and its receipt. A confirmed matching effect should not be repeated. An uncertain effect needs reconciliation or a retry authorized by the receiver's idempotency contract; absence from a completed-actions list is not proof it never happened.

Quick check — A client's transport connection drops mid-job and reconnects with a new session ID. What should the server do before resuming the job?

Check the decision

Where this leaves the design decision

The practical version of this lesson is a checklist you can run against your own MCP-based system: identify where job ownership is stored, confirm it is not the transport layer, confirm identity and job permission are checked on every protected request, and confirm completed external actions are logged somewhere that a retry will consult before acting again. None of that requires abandoning a stateless transport. Stateless operation can simplify distributing requests among server instances, provided durable business state and authorization remain available to whichever instance receives the request. The mistake is assuming that simplification extends upward into the business layer, where it does not belong and was never claimed to.

The teaching lesson on persistent operational state and typed handoffs works through a related case in more depth, covering what should survive when one autonomous actor hands work to another, and how to validate a handoff's claims rather than trusting its prose. That lesson's core distinction, that saving a narrative summary is not the same as preserving verifiable job state, is the same distinction this article applies specifically to protocol reconnection.

Continue with durable state and handoffs between actors

If the caller-versus-owner check in this article made sense, the course's lesson on memory handoffs and permissions extends the same durable-state discipline to handoffs between different autonomous actors.

Ready to put an agent to work?

Join the Prof Rod newsletter for one educational lesson a week, with worked examples attached. It is free to register for and separate from the Zero Employee community.