# JSON-RPC 2.0 over Streamable HTTP MCP: one request on the wire

> Trace one MCP tool call end to end: the JSON-RPC 2.0 over Streamable HTTP MCP envelope, the stateless POST, and what OAuth adds on top.

**TL;DR** An MCP request is a JSON-RPC 2.0 message sent as the body of an HTTP POST, which is what the Streamable HTTP transport specifies. Elaichi serves that at one address, POST https://api.elaichi.ai/mcp, statelessly: no session id, no server stream, every request carrying its own OAuth bearer token. The OAuth layer is what turns an anonymous envelope into a call made by a named person in a named organization.

## What actually goes over the wire

You are reading a proxy log, or a network capture, or your own client's debug output. You want to know which parts of an MCP call are the protocol and which parts are a vendor's choice. JSON-RPC 2.0 over Streamable HTTP MCP answers the first half. The message shape is JSON-RPC 2.0, the delivery mechanism is an HTTP POST, and almost everything else is negotiable.

MCP is the Model Context Protocol, the open standard that lets an AI client call tools on a server. This post walks one request through it at the wire level, in the order the bytes move. Elaichi is the worked example at each step, not the subject. Elaichi serves MCP at one endpoint, POST /mcp, speaking MCP over the Streamable HTTP transport as JSON-RPC 2.0 messages. If you are debugging a connection rather than studying the protocol, [the OAuth error guide](/blog/mcp-oauth-errors/) is the faster read.

One definition up front, because it recurs. An endpoint is a single HTTP address a client sends requests to. A grant is the record created when a person approves a client on a consent screen. It holds who they are, which organization they picked, and what the client may do.

## Why is JSON-RPC 2.0 over Streamable HTTP MCP the transport?

Because MCP separates the message format from the delivery mechanism. JSON-RPC 2.0 over Streamable HTTP MCP is the pairing the specification defines for remote servers: JSON-RPC 2.0 says what a message looks like, and Streamable HTTP says how it travels.

JSON-RPC 2.0 is a small specification. A request carries four fields: `jsonrpc` set to `"2.0"`, a `method` name, an optional `params` object, and an `id`. A response carries `jsonrpc`, the same `id`, and either `result` or `error`. A notification is a request with no `id`, and gets no response. The [JSON-RPC 2.0 specification](https://www.jsonrpc.org/specification) is the authority on all of it, and it is short enough to read in one sitting.

Streamable HTTP is the MCP transport that carries those messages over ordinary HTTP. The [MCP transports specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports) defines it. The client POSTs a JSON-RPC message to the server's single endpoint. The server answers with either a JSON body or a stream of Server-Sent Events. It replaced the older HTTP+SSE transport, which needed two endpoints.

So a tool call is a POST whose body is a JSON object. Nothing more exotic than that.

## The request: one POST, one JSON object

A tool call is a single HTTP POST with a JSON-RPC body naming the method `tools/call`. Here is the shape, with the HTTP layer and the JSON-RPC layer separated.

```
POST /mcp HTTP/1.1
Host: api.elaichi.ai
Content-Type: application/json
Accept: application/json, text/event-stream
Authorization: Bearer mcp_at_...

{
 "jsonrpc": "2.0",
 "id": 7,
 "method": "tools/call",
 "params": {
 "name": "execute_tool",
 "arguments": { "name": "...", "arguments": { } }
 }
}
```

Three parts of that block carry weight. The tool named is `execute_tool`, which [is only a naming indirection](/blog/execute-tool-is-only-naming-indirection/) and adds no privilege of its own.

The `Authorization` header is the whole of the caller's identity. Elaichi requires an MCP OAuth access token as a Bearer token on every request to /mcp. It accepts nothing else: not an Elaichi API token, not a browser session, not a pasted API key. A request without a valid token gets HTTP 401. The address itself carries no credential, and the protected-resource document advertises `bearer_methods_supported: ["header"]`.

The `Accept` header decides the response encoding. Elaichi answers with plain `application/json` by default. It sends a single Server-Sent Events `message` event only when the Accept header admits `text/event-stream` and not JSON.

The `id` is yours. The server echoes it back so you can match the response to the request when several are in flight.

## What stateless means in practice

Stateless means the server keeps nothing between requests, so each POST has to carry everything needed to authorize and route it. Elaichi's MCP endpoint is stateless: it keeps no session, never issues an `Mcp-Session-Id`, and ignores `Last-Event-ID`.

The transport specification allows a session, assigned by the server on initialize and echoed by the client on later requests. Elaichi does not use one. The consequence is simple. The bearer token is the only continuity between two calls, and every call re-reads the grant behind that token.

That re-read is why revocation is fast. Every MCP request re-reads the client's OAuth grant, so revoking the grant fails the client's very next request with a 401 `invalid_token` challenge. Disconnecting an app in Settings, Connected apps takes effect on the next call, not when the access token expires an hour later.

Stateless also means there is no server-to-client channel. GET and DELETE on /mcp get HTTP 405 with `Allow: POST`. Elaichi advertises `listChanged: false` for tools, prompts and resources, and sends no `notifications/tools/list_changed` message, because there is no stream to send one on. A client that caches a tool list decides for itself when to refresh.

Batching is refused. POST /mcp rejects a JSON-RPC batch array with error `-32600`, "Batch requests are not supported". A body that is not JSON gets `-32700`, "Parse error".

## What the OAuth layer adds to the envelope

OAuth adds identity, and identity is the thing JSON-RPC and Streamable HTTP deliberately leave out. The envelope says what to run. The token says who is running it, in which organization, and what they were allowed to approve.

The discovery chain starts from a failure. A request to /mcp with no token gets 401 with `WWW-Authenticate: Bearer resource_metadata="https://api.elaichi.ai/.well-known/oauth-protected-resource"`. That pointer is how a client finds the authorization server. Elaichi publishes three public discovery documents: the protected-resource document (RFC 9728), the authorization-server document (RFC 8414) and an OpenID configuration.

From there the client registers itself through OAuth dynamic client registration (RFC 7591) at POST /oauth/register, which needs no Elaichi credential. Claude uses a Client ID Metadata Document only when the authorization server advertises support for one, and otherwise falls back to dynamic client registration ([Anthropic's connector authentication docs](https://claude.com/docs/connectors/building/authentication), checked October 2026). Elaichi does not advertise that support, so Claude registers the ordinary way.

The client then sends the person to GET /oauth/authorize with PKCE: `code_challenge` and `code_challenge_method=S256`. S256 is the only method accepted, and a missing `code_challenge_method` is refused rather than assumed. The person approves on Elaichi's consent screen. The client exchanges the code at POST /oauth/token and gets back an opaque access token, a refresh token and `expires_in` 3600.

Four lifetimes govern the rest. A consent request lasts 30 minutes, an authorization code 60 seconds, an access token 1 hour and a refresh token 30 days. Each refresh returns a new refresh token and invalidates the one presented.

What the token then carries matters more than its format. Every call made with a grant's tokens acts as the person who approved that grant, in the organization they picked. Permissions are looked up on each request rather than frozen into the token. That is why one address serves every organization: the grant, not the URL, decides which organization and which person a client reaches. Elaichi's address is the same string for everyone, https://api.elaichi.ai/mcp. The design trade-off behind that choice is covered in [the per-team endpoint comparison](/blog/one-endpoint-vs-per-team-endpoint/).

## How scope refusals come back

A scope refusal is not an HTTP error. It arrives as a normal 200 with the failure in the body, which surprises people reading logs for the first time.

When a call falls outside the grant's scopes, /mcp answers HTTP 200 with an in-band tool error (`isError: true`) for a tool call. The response also carries a `WWW-Authenticate` challenge with `error="insufficient_scope"` and the scope needed. The message names the consent checkbox and lists the scopes the connection holds. It tells the model that reconnecting is the fix, because a grant's scopes never change after consent.

The reasoning is practical. A 403 would be handled by the client's HTTP layer and lost. A 200 with a readable error reaches the model, which can tell the person what to do. The same applies to a missing `tool:execute` permission, which is a role problem rather than a scope problem, and says so. A restriction refusal arrives the same way, and [the end-to-end walkthrough](/blog/what-a-tool-restriction-looks-like-on-the-wire/) shows what the caller sees.

True HTTP errors are reserved for conditions above the protocol. A bad or missing token gets 401. A caller who is no longer an active member of the organization, or an organization with no active plan, gets 403. A token that has spent its rate limit of 120 requests per 60 seconds gets 429 with `Retry-After: 60`.

## When you do not need to know any of this

If you are connecting Claude or ChatGPT to a hosted MCP server, you will never see a JSON-RPC envelope. The client handles registration, PKCE, token refresh and transport negotiation, and you paste a URL. Reading the wire format buys you nothing.

It starts to matter in three cases. You are writing an MCP client or server yourself and need to know which fields are required. You are debugging a connection that fails before the tool list arrives. There the distinction between a 401, a 405 and a 200 with `isError` tells you which layer broke. Or you are reviewing a vendor and want to confirm what travels in the Authorization header versus the URL.

For the second case, start with the symptom rather than the specification: [MCP tools not showing up](/blog/mcp-tools-not-showing/) walks the list that covers most of them.

## The trade-off in statelessness

Stateless costs something. Without a session there is no server push, so a client cannot be told its tool list went stale. It finds out on the next call. Elaichi's only freshness signal is a 60-second cache hint on list results under the 2026-07-28 revision. How long a client keeps its copy is the client's decision.

It also means every request pays the full authorization cost: read the token, read the grant, resolve the person's permissions. Elaichi caches some of that briefly, but the authorization itself is taken again at call time, which is the point.

What you get in return is that nothing is stuck. A revoked grant, a removed member, a suspended account: each fails on the next call, because there is no session holding the old answer. Role and restriction changes are the exception. They take about two minutes to reach every point of presence, which [the governance overview](/blog/mcp-governance/) covers.

If you want to see the whole catalog of what sits behind one of these endpoints, [/connectors/](/connectors/) lists it.

## FAQ

### What transport does MCP use for remote servers?

Remote MCP servers use the Streamable HTTP transport, in which the client sends JSON-RPC 2.0 messages as the body of an HTTP POST to a single endpoint, and the server replies with either a JSON body or a stream of Server-Sent Events. It replaced the older HTTP+SSE transport, which required two separate endpoints. The MCP transports specification at modelcontextprotocol.io defines it.

### Does an MCP request need a session id?

Not necessarily. The MCP transport specification allows a server to assign an Mcp-Session-Id on initialize and have the client echo it on later requests, but it does not require one. Elaichi's MCP endpoint is stateless: it issues no session id, ignores Last-Event-ID, and treats the OAuth bearer token on each request as the only continuity between calls.

### Where does the access token go in an MCP request?

In the Authorization header, as a Bearer token, never in the URL. Elaichi's protected-resource document advertises bearer_methods_supported as ["header"] only, and the MCP endpoint address carries no token or query string. A request to Elaichi's /mcp without a valid Bearer token gets HTTP 401 with a WWW-Authenticate challenge pointing at the discovery document.

### Why does a permission failure return HTTP 200 instead of 403?

Because the model needs to read it. When an MCP call falls outside the grant's scopes, Elaichi answers HTTP 200 with an in-band tool error (isError: true) plus a WWW-Authenticate header carrying error="insufficient_scope". An HTTP 403 would be consumed by the client's transport layer and never reach the model, so the person would see a generic failure instead of a message naming the consent checkbox they need to approve.

### Can an MCP client send several JSON-RPC calls in one HTTP request?

JSON-RPC 2.0 defines a batch as an array of request objects, but support is per server. Elaichi's POST /mcp rejects a batch array with JSON-RPC error -32600, "Batch requests are not supported", so each call is its own POST. A body that is not valid JSON gets error -32700, "Parse error".

## Read next

- [MCP execute_tool indirection security](/blog/execute-tool-is-only-naming-indirection/) — MCP execute_tool indirection security rests on one rule: the wrapper unwraps to a name and arguments, then meets the gates a direct call would meet.
- [MCP enterprise-managed authorization: what IT owns](/blog/enterprise-managed-authorization-extension/) — MCP enterprise-managed authorization gets cited as a spec feature. Here is what is checkable, and what SAML, OIDC and SCIM already decide for you.
- [How MCP tool restriction enforcement works, end to end](/blog/what-a-tool-restriction-looks-like-on-the-wire/) — Follow one rule through MCP tool restriction enforcement in Elaichi, from the admin who writes it to the final check on the resolved outbound URL.
