Blog • Claude Code / MCP

After Claude Code 2.1.310, how should a non-interactive MCP elicitation timeout be written in JSON so the same tool is not called again?

On October 23, 2026 Claude Code 2.1.310 changed one non-interactive error. When an MCP tool call times out while the server's elicitation is still unanswered, the model is now told that no answer came and not to repeat the call.

The MCP docs updated the same day: in a non-interactive session, if a task asks for input, Claude Code asks the server to cancel it and does not run Elicitation hooks or the Agent SDK onElicitation callback. A larger timeout does not create someone to answer. Whether the next claude -p run calls the tool again depends on the JSON you store, not on the model stopping by itself.

Stopping a log when OAuth does not match is in the OAuth stop. A stdio server loaded again by the allowlist is in the env allowlist. Replaying an item across providers is a different envelope, in the Open Responses replay. This article covers only the log fields after a non-interactive elicitation timeout.

Keep this in mind: 2.1.310 splits no answer from try again. The sections below follow status, reason, and repeat, and show which field stops the next tool call in a non-interactive log.

2.1.310 pulls an unanswered elicitation out of retryable timeouts

The change is in the Claude Code changelog, and the release notes are at v2.1.310. In plain terms: in a non-interactive session, an MCP tool call that times out while elicitation is unanswered now tells the model that no answer came and not to repeat the call. An interactive terminal where someone can fill the form does not use this rule.

The MCP connection docs write each server's timeout in milliseconds. Values below 1000 are ignored, and progress notifications do not extend that wall clock. A non-interactive run cannot show the elicitation dialog, so 120000 only waits longer for an answer that will not appear.

timeout on a server entry. It does not supply a missing answer.
{
  "mcpServers": {
    "reports": {
      "type": "http",
      "url": "https://mcp.example.com/mcp",
      "timeout": 120000
    }
  }
}

A one-shot claude -p string does not become an MCP task either. The docs say that run does not get tasks; following a task needs an interactive resume or --input-format stream-json. An elicitation timeout can still land on an ordinary tool call, so the log cannot require a taskId.

Which fields are required when the server returns a task that needs input?

On protocol revision 2026-07-28, a long job can answer tools/call as a task. The fields follow the MCP tasks extension, at the tasks extension. The Claude Code docs require resultType task, and taskId, status, createdAt, lastUpdatedAt, and ttlMs must all be present. status is only working, input_required, completed, failed, or cancelled. input_required also carries inputRequests, and elicitation/create is handled only in an interactive terminal. A non-interactive session cannot serve it and asks the server to cancel.

Task returned by the server. status is input_required and the method is elicitation/create.
{
  "resultType": "task",
  "taskId": "export-7f3a9c2e",
  "status": "input_required",
  "createdAt": "2026-10-23T16:44:00Z",
  "lastUpdatedAt": "2026-10-23T16:44:00Z",
  "ttlMs": null,
  "inputRequests": {
    "confirm": {
      "method": "elicitation/create",
      "params": {}
    }
  }
}

This JSON is the server's receipt, not your retry switch. A non-interactive run that sees input_required should cancel, not paste the same object into the next claude -p input and run it again.

How does the local log say not to call again?

Do not stuff repeat into the protocol status. repeat and reason are your own log fields. For a non-interactive run with nobody to answer, use reason elicitation_unanswered. For the 2.1.310 timeout error, use reason elicitation_timeout. Both set repeat to false. taskId may be absent, because an ordinary tool call is not always a task.

A log that can stop the next non-interactive call. repeat is false.
{
  "session": "non_interactive",
  "tool": "export_report",
  "taskId": "export-7f3a9c2e",
  "status": "input_required",
  "reason": "elicitation_unanswered",
  "repeat": false
}

Pin the shape with the schema below. session is only interactive or non_interactive. status uses only the five protocol values. reason is where you separate the stop from outcome unknown. additionalProperties is false so the model's whole error text is not stuffed into an undeclared field.

Local log schema. A legal repeat type does not mean this call may run again.
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "additionalProperties": false,
  "required": ["session", "tool", "status", "reason", "repeat"],
  "properties": {
    "session": { "enum": ["interactive", "non_interactive"] },
    "tool": { "type": "string", "minLength": 1 },
    "taskId": { "type": "string" },
    "status": {
      "enum": ["working", "input_required", "completed", "failed", "cancelled"]
    },
    "reason": {
      "enum": ["elicitation_unanswered", "elicitation_timeout", "outcome_unknown", "none"]
    },
    "repeat": { "type": "boolean" }
  }
}

Why reject repeat true after the schema passes?

The schema above only requires repeat to be a boolean. The log below has every field and legal types, but the session is non-interactive, reason is elicitation_unanswered, and repeat is true. The gate should return repeat_conflict, and the next non-interactive run should not send this tool call.

The schema can pass. The gate should still return repeat_conflict.
{
  "session": "non_interactive",
  "tool": "export_report",
  "taskId": "export-7f3a9c2e",
  "status": "input_required",
  "reason": "elicitation_unanswered",
  "repeat": true
}
Log you seeGateNext non-interactive run
Non-interactive, input_required, repeat falsestopDo not send this tool call.
The same unanswered case, but repeat truerepeat_conflictReject. Set repeat to false first.
reason is elicitation_timeout and repeat is falsestopThe 2.1.310 timeout also means do not call again.
session is interactivenot_headlessDo not use this non-interactive stop. An interactive session can answer the elicitation.

Why is outcome unknown not the same stop?

The docs also describe a transcript line: after the server rejects the task id or returns another permanent error, Claude Code stops following the task and writes outcome unknown. The follow-up there is that a second call may repeat the work, so before calling again you ask the server what already happened. That is not the same instruction as 2.1.310's do not call again.

The gate checks the schema, then whether the session is non-interactive. reason outcome_unknown returns check_server and is not folded into stop. Unanswered elicitation, elicitation_timeout, and input_required in a non-interactive session require repeat false and return stop. Other combinations return no_rule. This function does not decide a new business task for you.

Gate for a non-interactive log. Do not collapse check_server and stop into one boolean.
def actual_type(data: object) -> str:
    if data is None:
        return "null"
    if isinstance(data, bool):
        return "boolean"
    if isinstance(data, list):
        return "array"
    if isinstance(data, int):
        return "integer"
    if isinstance(data, float):
        return "number"
    if isinstance(data, str):
        return "string"
    if isinstance(data, dict):
        return "object"
    raise TypeError(type(data))

def validate(data: object, schema: dict) -> list[str]:
    errors: list[str] = []
    if not isinstance(schema, dict):
        return errors
    types = schema.get("type")
    if isinstance(types, str):
        types = [types]
    actual = actual_type(data)
    if types and not any(t == actual or (t == "number" and actual == "integer") for t in types):
        errors.append("type")
    if "enum" in schema and not any(value == data for value in schema["enum"]):
        errors.append("enum")
    if isinstance(data, str) and "minLength" in schema and len(data) < schema["minLength"]:
        errors.append("minLength")
    if isinstance(data, dict) and schema.get("properties"):
        for key in schema.get("required") or []:
            if key not in data:
                errors.append(f"required:{key}")
        if schema.get("additionalProperties") is False:
            for key in data:
                if key not in schema["properties"]:
                    errors.append(f"additional:{key}")
        for key, sub in schema["properties"].items():
            if key in data and data[key] is not None:
                errors.extend(validate(data[key], sub))
    return errors

def headless_repeat(record: dict, schema: dict) -> str:
    if validate(record, schema):
        return "schema"
    if record.get("session") != "non_interactive":
        return "not_headless"
    if record.get("reason") == "outcome_unknown":
        return "check_server"
    if record.get("reason") in {"elicitation_unanswered", "elicitation_timeout"} or record.get("status") == "input_required":
        if record.get("repeat") is not False:
            return "repeat_conflict"
        return "stop"
    return "no_rule"

How do you separate the stop from an unknown outcome in JSONNote?

  1. 1
    Check the shape with the schema

    Open JSON Schema. Paste the log schema in the first field and the stop log, or the one with repeat true, in the second. A missing tool or a bad reason is flagged first. repeat true can still pass, so the gate still matters.

  2. 2
    Then see whether repeat is a boolean

    Open JSON Format. Paste the whole log. If repeat is the string false rather than the boolean false, the schema fails and the gate returns schema. Do not treat that as already stopped.

  3. 3
    Then diff the task receipt and the local log

    Open JSON Diff. Put the server's input_required task on the left and the local log on the right. reason and repeat appearing only on the right are the stop. If neither side has repeat, the next non-interactive run still has nothing that stops the call.

Common questions

If I raise timeout to a few minutes, can a non-interactive elicitation wait for an answer?

No. A non-interactive session has nobody to fill the elicitation. A longer timeout only stretches the wait for an answer that is not coming. The 2.1.310 error tells the model not to call again. It does not turn this timeout into an automatic retry next time.

Can outcome unknown also be stored as repeat false?

Do not use the same stop. For outcome unknown the docs say a second call may repeat work already done, so ask the server before calling again. The gate returns check_server for that reason. The elicitation timeout is the stop.

Does a one-shot claude -p enter an MCP task?

The docs say no. That run does not get MCP tasks. To follow a task, resume interactively or use --input-format stream-json and meet the automatic-background conditions. An elicitation timeout can still happen on an ordinary tool call, so taskId is not required in the log.

Should input_required in an interactive terminal also be logged as stop?

No. This stop covers only non-interactive runs. An interactive terminal can answer elicitation/create. The docs also say that cancelling a task in a non-interactive session does not run Elicitation hooks or onElicitation. Do not apply the headless repeat false to an interactive session.

Should repeat false be written into the protocol status?

No. Keep protocol status as working, input_required, completed, failed, or cancelled. Leave repeat and reason in the local log. Stuffing false into status breaks the task extension and hides whether this was an elicitation timeout or outcome unknown.

Summary

2.1.310 changes the timeout where nobody answered in a non-interactive session. It is not a retry policy for every MCP error.

Decision: for a non-interactive unanswered elicitation or timeout, log repeat false and stop the next call; reject repeat true even when the schema passes; for outcome unknown, ask the server first and do not use the same stop.

Next: check the log shape with Schema, confirm repeat is a boolean with Format, and diff the task receipt against the local stop.

Back to the blog