Skip to content
Chapter 4 of 7 · ElicitationThe requestedSchema restriction

13 min read

The requestedSchema restriction

On this page

requestedSchema is the field of an elicitation request that describes the form the client is being asked to render. It is JSON Schema, and the protocol restricts it to a much smaller dialect of JSON Schema than the one you write for tool arguments: a single object at the top level, with properties and required, and nothing but primitives underneath.

The restriction follows from who does the rendering. A tool's inputSchema is consumed by a model, which reads arbitrary JSON Schema as text and produces JSON to match it, so the full vocabulary of the schema language is available there and topic 6.4, on tools, treats it as such. An elicitation schema is consumed by a client that must turn it into a user interface, immediately, for a server it has never seen, with no design input from you. That client has to map every construct in the schema onto a widget. A string becomes a text field, a boolean becomes a checkbox, an enum becomes a select. An anyOf of three object variants becomes a question about interface design that no generic renderer can answer, and a nested object becomes a nested form, which is where consistency across clients collapses.

The chain under that is short and every link is checkable. A schema describes values; a form is a set of widgets. Rendering means choosing one widget per construct, and for a primitive the choice is forced — one value, one input — so every client picks the same widget and a server can predict what the user is looking at. A nested object is not one value, so nothing forces the choice, and the reasonable options genuinely differ: draw a sub-form, draw a text area and let the user type JSON into it, or refuse a request containing something the client never agreed to accept. Three clients picking three of those is not three bugs. It is one under-specified instruction answered three ways, and the specification's response is to remove the instruction from the vocabulary rather than to pick a winner among the answers.

What a client can render knowing nothing about you

The specification names the forms a server may use, and the list is short enough to hold in your head:

Schema formAllowedKeywords the specification names
stringyestitle, description, minLength, maxLength, format (email, uri, date, date-time)
number / integeryestitle, description, minimum, maximum
booleanyestitle, description, default
enum of stringsyesenum, enumNames
object as a property's valueno—
array of anythingno—
oneOf / anyOf / allOfno—
$ref to a shared definitionno—

The top-level container is itself an object with properties and required, and that is the only object in the document. Everything under properties is a leaf. A shipping address, which in a tool's own arguments would carry a nested country object holding a code and a name, becomes this:

json
{
  "message": "Where should shipment SHP-4471 be delivered?",
  "requestedSchema": {
    "type": "object",
    "properties": {
      "street": {
        "type": "string",
        "title": "Street address",
        "maxLength": 120
      },
      "city": { "type": "string", "title": "City", "maxLength": 60 },
      "postcode": {
        "type": "string",
        "title": "Postcode",
        "description": "UK format, e.g. SW1A 1AA",
        "minLength": 5,
        "maxLength": 8
      },
      "country_code": {
        "type": "string",
        "title": "Country",
        "enum": ["GB", "IE", "FR", "DE", "NL"],
        "enumNames": ["United Kingdom", "Ireland", "France", "Germany",
                      "Netherlands"]
      }
    },
    "required": ["street", "city", "postcode", "country_code"]
  }
}

The nested country did not survive as a nested country and it did not need to. The display name was never the user's to supply — a country code determines its name, so the server holds one side of that pair and asking for both invites them to disagree. enumNames carries the human-readable labels in the one place they belong, which is the rendering, and the value that comes back is a code your system can act on. That pattern generalises: whenever a nested object appears in a draft schema, one of its fields is usually the thing the user actually chooses and the rest are derivable from it.

The word carrying that sentence is derivable, and it is worth taking literally. A country code determines a country name, and the name determines the code, so the pair holds one piece of information written twice. Asking a user for both makes it possible to receive a code and a name that disagree, and there is no correct handling of that response: you have to keep one and discard the other, which is a decision the user made without being told they were making it. Asking only for the field the others depend on makes the contradiction unrepresentable rather than unlikely. That is the test to run over any nested structure before flattening it — find the field the rest are functions of, ask for that one, and compute the others on the side that owns the mapping.

Two fields in that request do work that is easy to skip. The message is prose addressed to the person, and it is the only place you can put context the schema has no room for — which shipment this is, why the address on file was not usable, what happens after they answer. A schema of four well-titled inputs under the message Address required gives the user four boxes and no reason to trust them. The description on each property is the second-line explanation a client can show as helper text or a tooltip, and it is where format conventions belong, because SW1A 1AA in a description prevents more bad postcodes than any length constraint will.

The format keyword is worth using and worth not relying on. A client that recognises format: "date" may show a date picker, which removes an entire class of ambiguity about whether the user meant the third of April or the fourth of March; a client that does not recognise it renders a text field and the value arrives as whatever the user typed. Both outcomes are conformant, so format is a request for better rendering rather than a constraint on the string, and the parsing you would have written for the plain text field still has to exist.

Designing inside the limit rather than around it

The forms that cause trouble are the ones where the missing construct was carrying real structure. Three come up repeatedly, and each has a shape that fits.

A variable-length list is the most common. A notify_reviewers operation wants an array of email addresses, and arrays are not available. The temptation is a comma-separated string, which works in the sense that it arrives, and fails in the sense that you now own a parser for user-typed text with no client-side feedback about what separates entries. It is workable when the list is genuinely free-form and you validate hard on arrival, and it is worse than the alternative when the candidate set is known: five booleans, one per reviewer, titled with their names, is a rendering every client handles and produces a response with no parsing at all. The limit bites when the candidate set is large, and at that point the honest answer is that a picker over 200 reviewers is not an elicitation. It is a feature of the client's own interface, or a tool the model calls to search, and the operation should be structured so the model resolves the list before the call rather than the server collecting it during one.

A conditional field is the second. "If they choose express shipping, ask for a delivery window" is an if/then in JSON Schema, and it is not available either. Both fields go in the same flat object, the conditional one is left out of required, and its description states when it applies. The client renders both, the user fills in what is relevant, and the conditional logic lives in your handler where you can express it properly.

The third is a value with a sensible default. Booleans carry default in the schema and other types do not, which reads as an inconsistency until you notice what a default means in this context: it is a pre-filled answer, and the field is being rendered precisely because the user is expected to change it. For a number or a string, put the default in the operation instead — accept the field as optional, and when it comes back absent, apply the value you would have pre-filled. State it in the description so the user knows what happens if they leave it alone.

python
class ShipmentOptions(BaseModel):
    country_code: Literal["GB", "IE", "FR", "DE", "NL"]
    insured_value_gbp: int | None = Field(
        default=None,
        ge=0,
        le=5000,
        description="Leave blank for the default cover of £50.",
    )
    signature_required: bool = False

result = await ctx.elicit(
    message=f"Where should shipment {shipment_id} be delivered?",
    schema=ShipmentOptions,
)

The ge and le become minimum and maximum in the emitted schema, and a client that honours them will keep the user inside the range while they type. What that does for you is reduce the number of round-trips spent on obviously wrong input. What it does not do is guarantee anything about the value that arrives, and chapter 6, on validating what comes back, is about the gap between those two sentences.

A credential is the case the ownership rule gets wrong

A migration server needs the production database password to run ALTER TABLE. The credential is unambiguously the user's, it exists nowhere your server can read, and by chapter 1's ownership rule it looks like the strongest elicitation candidate in this entire topic. The specification forbids it anyway: servers must not use elicitation to request sensitive information, and no client behaviour or transport property changes that.

The prohibition is doing three separate jobs. A password typed into an elicitation dialog arrives in your process as an ordinary string in an ordinary JSON-RPC message, and it passes through whatever request logging, tracing and error reporting that message passes through — a stack trace including params is enough to put it in a log aggregator with a 400-day retention policy. Elicitation results are also not necessarily invisible to the model; a handler that echoes the collected content into its result, or a client that surfaces the exchange in the transcript, puts the secret into a context window that will be sent to an inference provider. Neither of those requires anyone to behave badly.

The third job is the important one and it is about the user rather than about you. The client renders your dialog in the client's own visual language, which means a prompt from your server looks exactly like a prompt from any other server the user has connected. A user who has learned that typing their database password into an MCP dialog is normal has no way to distinguish your legitimate request from one sent by a server they installed last week to summarise PDFs. Every well-behaved server that asks for a secret makes the malicious request more plausible, and the user is the one who pays for that. The same reasoning covers API keys, OAuth tokens, private keys, card numbers, and one-time passcodes, and it covers them regardless of how carefully you handle what arrives.

The sentence a reader is most likely to write after that paragraph is that their own handling is careful, so the prohibition is really aimed at other people's servers. It is worth seeing exactly where that stops working. The first two jobs — the logging exposure and the model exposure — are properties of your process, and careful handling genuinely reduces both. The third is not a property of your process at all. What trains a user to type a database password into an MCP dialog is the population of dialogs they have seen, and your server's contribution to that population is the same whether the string is scrubbed from your logs a microsecond later or kept for a year. A request cannot be made safe by handling its answer well, because the part that does the damage has already happened by the time the answer exists: a prompt appeared on somebody's screen and looked ordinary.

What replaces it is out-of-band credential delivery, meaning the secret reaches the server through a channel that is not the model's conversation and not the client's dialogs, which is the ordinary way servers have always been configured:

json
{
  "mcpServers": {
    "migrator": {
      "command": "uvx",
      "args": ["migrator-mcp"],
      "env": { "MIGRATOR_DATABASE_URL": "${env:MIGRATOR_DATABASE_URL}" }
    }
  }
}

The secret is placed by whoever administers the machine, it lives in the environment or a secret manager, it never enters a message, and it is scoped to a connection rather than to a moment. Where the credential belongs to the end user rather than the operator, the mechanism is an authorization flow — the server returns an error carrying a URL, the user authenticates with the upstream provider directly, and your server receives a token it never asked a human to type. Both routes have the property elicitation lacks, which is that the secret is never rendered as a form field in a window the user did not initiate.

The line between sensitive and ordinary is not always crisp, and the workable test is whether the value would still be dangerous if it appeared in a log line six months from now. A room preference would not. A postcode is personal data with handling obligations and is not a credential. A bearer token is a credential whether or not it expires in an hour.

The size of question elicitation is for

The restriction is not a security boundary. maxLength: 8 on a postcode and enum on a country code describe a form; they do not constrain what arrives, and treating the schema as an input filter is the error chapter 6 takes apart in detail.

Nor is it a form builder that happens to be limited. The flat-primitives rule is often read as a temporary shortfall that a future revision will lift, and the more useful reading is that the shape of the schema tells you the size of question elicitation is for. One decision, or a small set of decisions that belong to the same moment, renders well everywhere and takes a user a few seconds. A configuration screen does not fit, and the reason it does not fit is that it was never a mid-operation question — it is state that should have been collected once, stored, and read on every subsequent call. When a draft schema grows past five or six fields, the constraint is telling you something about the operation rather than about the protocol.

It is also not a general statement about MCP schemas. Tool inputs are full JSON Schema and always were, so a create_shipment tool can take a deeply nested address object as an argument without difficulty. The restriction applies only where a client has to render, which is worth remembering when the flattening feels awkward: the nested version is still available on the path where the model supplies the value, and a value the model can supply was never an elicitation candidate to begin with.

Both halves of this chapter are the same constraint seen from two sides. What a client will render decides the shape of the schema, and what a client will render your dialog as — indistinguishable from every other server's — decides what you may put in it. A flat object of primitives is small enough to render identically everywhere, and a question small enough to render identically everywhere is a question a user can answer without leaving what they were doing.

With a legal schema and a client that has declared it can answer, the request goes out and the operation stops. What comes back is a response with three possible actions, and a handler that recognises two of them has already lost the distinction that matters most.

Elicitation — read the current version at https://daviddominguez.dev/learn/en/elicitation/the-requestedschema-restriction

Back to top