The 2026-07-28 MCP spec does two things people keep conflating. It deprecates sampling — the feature that let a server reach back through the client to call the model — and it keeps elicitation, the feature that lets a server reach back to the human. If you only remember one distinction from the rewrite, make it that one. Sampling is on its way out (SEP-2577; new servers should call provider APIs directly). Elicitation stayed, got a URL mode, and is now the sanctioned way to do the flows you were previously hacking around: OAuth, credentials, and payments.

This is a walkthrough of that URL mode, end to end — what it is, why the obvious form-based approach is a security bug, and the concrete request/response shapes under the new stateless core.

The problem URL mode solves#

Say your MCP server wraps a third-party API — a calendar, a billing provider, a GitHub org — and a tool call arrives for a user who hasn't authorized yet. You need an OAuth token. The tempting move is to elicit it: pop a form, ask for the token or an API key, read it out of the response.

Don't. A form-mode elicitation returns whatever the user typed in the response content, and that response travels back through the MCP client. From there it can land in the model's context window, in transcript logs, in traces. A secret that passes through the model context is a secret you have leaked. OAuth authorization codes, refresh tokens, and card numbers all fail this test.

Form mode is for data you can safely show the model. URL mode is for everything you can't.

URL mode fixes this by never carrying the secret inline. The server asks the client to send the user to a trusted URL. The sensitive exchange happens between the user's browser and the provider. Your server finds out the result through its own server-side callback — out of band, off the model's path.

Step 1 — Feature-detect, then return a URL elicitation#

Elicitation is an optional client capability, and URL mode is a sub-capability the client advertises during capability exchange. Check for it before you rely on it. When a tool discovers it lacks authorization, return a URL-mode elicitation instead of failing:

// inside your tool handler
if (!(await hasValidToken(userId))) {
  const authUrl = buildAuthorizationUrl(userId); // see step 2
  return {
    // URL mode: no schema, just a trusted URL to open
    elicitation: {
      mode: "url",
      message: "Connect your Calendar account to continue.",
      url: authUrl,
    },
  };
}

Form mode, by contrast, carries a requestedSchema — a restricted subset of JSON Schema — and expects the value back inline. That is the shape you use for a name or a confirmation, and the shape you must not use for a token:

{
  "jsonrpc": "2.0", "id": 1, "method": "elicitation/create",
  "params": {
    "message": "Please confirm the calendar to use",
    "requestedSchema": {
      "type": "object",
      "properties": { "calendar": { "type": "string" } },
      "required": ["calendar"]
    }
  }
}

The client resolves either kind with one of three actions — accept, decline, or cancel. Handle all three; only accept means proceed. cancel (the user closed the dialog) is not the same as decline (the user said no), and neither is an error to swallow silently.

Step 2 — Mint the URL and bind it to an identity#

This is the step people skip, and it is the one that matters for security. Your server acts as an OAuth client to the third party: it generates the authorization URL and stores server-side state that binds this elicitation to this user. The state parameter is not decoration — it is how your callback knows who just authorized, and it is your CSRF defense.

function buildAuthorizationUrl(userId: string) {
  const state = randomToken();                 // unguessable
  savePendingAuth(state, { userId, exp: in10min() }); // bind + expire
  const u = new URL("https://provider.example/oauth/authorize");
  u.searchParams.set("client_id", CLIENT_ID);
  u.searchParams.set("redirect_uri", `${BASE}/callback`);
  u.searchParams.set("scope", "calendar.read");
  u.searchParams.set("state", state);
  return u.toString();
}

Never send the user to a URL you didn't mint. A URL-mode elicitation is a redirect you are vouching for; treat an unbound or externally-supplied URL as an attack.

Step 3 — The callback resumes the tool, not the elicitation#

The user completes consent in their browser and the provider redirects to your /callback. That handler — a normal HTTP route on your server, entirely outside the MCP transport — validates state, exchanges the code for a token, and stores it against the bound userId:

app.get("/callback", async (req, res) => {
  const pending = takePendingAuth(req.query.state); // one-time, must exist
  if (!pending) return res.status(400).send("Invalid state");
  const token = await exchangeCode(req.query.code); // server-to-server
  await storeToken(pending.userId, token);          // never returned to MCP
  res.send("Connected. Return to your assistant.");
});

The token now lives on your server, keyed to the user, having never touched the client or the model. When the agent retries the original tool call, hasValidToken(userId) is true and the tool runs.

How this rides the stateless core#

Under the 2026-07-28 rewrite, MCP is stateless at the protocol layer — there's no held connection to push a server-initiated request down. Elicitation is delivered through Multi Round-Trip Requests (SEP-2322): instead of streaming a prompt, the server returns an InputRequiredResult carrying inputRequests and an opaque requestState. The client gathers the answers and re-issues the original call with inputResponses and the echoed requestState. Because everything needed to resume is in the payload, any server instance can pick the retry up — which is the whole point of going stateless.

For URL mode this composes cleanly: the "answer" the client sends back is simply the user finished the out-of-band flow, and your server confirms it by checking the token store your callback populated. The migration is the same shape covered in Make Your MCP Server Stateless Before July 28, applied to the one interaction that used to require a held connection.

The one-line rule#

If the value can be shown to the model, use form mode and read it inline. If it can't — an OAuth code, an API key, a card — use URL mode, keep the exchange in the browser, and learn the result through your own callback. The spec finally has a first-class answer for the second case. Use it, and stop putting secrets in the context window.