Elicitation III: url mode, or how to take a payment safely
One hard rule
Atrium Works charges for after-hours bookings. The tool needs a card.
Do not ask for it with a form. Not with a well-designed form, not over TLS, not with the field marked sensitive. The specification says a server must not use form mode to request passwords, API keys, access tokens or payment credentials, and this is one of the few places MCP uses that word.
The reason is not that the transport is insecure. It is that a form answer passes through the client and into the model's context, and a context window is a place things get written down — logged, cached, included in the next request, retained for debugging by systems nobody is thinking about at the moment the card number arrives.
The card machine the waiter brings to the table
The waiter carries the machine over, you type your PIN, and the machine talks to the bank. The waiter is standing right there and never learns the PIN.
Now notice why that arrangement exists. It is not because the restaurant suspects the waiter. It is because the waiter writes everything down — the order, the table, the time — and a PIN that passed through the waiter would end up in the same notebook, where it does not belong and cannot be unwritten.
The model's context is that notebook. A beginner takes away that sensitive things go straight to the bank. An engineer gets the precise argument: the issue is not interception, it is retention in a system that logs by design — and the fix is to keep the data out of band entirely rather than to secure the channel it would travel on.
URL mode
@mcp.tool()
async def pay_surcharge(booking_id: str, ctx: Context) -> str:
"""Take the after-hours surcharge that confirms an out-of-hours booking."""
result = await ctx.elicit_url(
message="A 20 EUR after-hours surcharge confirms this booking.",
url=f"https://pay.atrium.example/surcharge/{booking_id}",
elicitation_id=f"surcharge-{booking_id}",
)
if result.action == "accept":
return "Opened the payment page. The booking is held for one hour."
return "No surcharge taken, so the booking was not confirmed. It expires in one hour."The server hands over a URL, not a schema. The client shows it, asks the user to consent, and opens it. The user pays on your payment provider's page, in their own browser. Nothing sensitive goes near the protocol.
What comes back is thin, deliberately: accept means the user consented to opening the link. It does not mean they paid. The client never learns what happened on that page, which is the entire point — it is out of band from the client too, not just from the model.
What the client must do
Two obligations, and both matter.
Show the full URL and get explicit consent. The user must see where they are being sent, because a URL is supplied by a server and a server may be hostile.
Never fetch it automatically. The client shows and opens; it does not request. A client that pre-fetched a server-supplied URL would be a server-side request forgery engine, which is Lesson 32.
Learning that the payment happened
Since accept only means consent, the server finds out through its own back channel — a webhook from the payment provider, which is where that information actually lives. It can then tell the client the elicitation is finished:
@mcp.tool()
async def confirm_surcharge(booking_id: str, ctx: Context) -> str:
"""Record a surcharge payment reported by the payment provider."""
await ctx.session.send_elicit_complete(f"surcharge-{booking_id}")
return f"Surcharge received. Booking {booking_id} is confirmed."That is what elicitation_id is for: it names the pending interaction so it can be resolved later. Without it the client has a link it showed once and no idea whether it mattered.
Choosing between the modes
The test is one question: would you mind if this value appeared in a log file?
Form mode for anything you would happily print. Which room. Which date. Whether to proceed. How many people. These are the questions that make a tool usable, and they belong in a form because a form is fast and structured and validated.
URL mode for anything you would not. Passwords, API keys, tokens, card numbers, anything covered by a regulation. Also for genuine third-party OAuth, where the whole point is that the credential is exchanged between the user and the provider with you as a bystander.
The line is not "sensitive" in a vague sense; it is specifically secrets and credentials. A member's name is personal data and belongs in a form — you already have it. Their card number is a credential and does not.
What to take into the next lesson
Form mode must never carry a password, key, token or card number, because a form answer lands in the model's context and contexts get written down; URL mode sends the user out of band and returns only whether they consented, with elicitation_id and send_elicit_complete to close the loop later. The test is whether you would mind seeing the value in a log. Next: work that takes longer than a request.