Soklet Logo

Model Context Protocol

MCP Input and Request State

Ordinary tool, prompt, and resource invocations can return input_required when they need a client-side operation before completing. Soklet validates declared client capabilities, carries keyed responses into a retry, and can bind protected state to the original operation and admitted identity. Durable Tasks can also request input, but their responses travel through tasks/update instead of retrying the original tool invocation.

The multi-round flow

  1. Register every client request an operation might emit.
  2. On an initial invocation, return McpInputRequiredResult with one or more keyed McpInputRequest values, retry state, or both.
  3. The client performs those requests and retries the original operation with inputResponses and any requestState.
  4. Read verified values from McpRequestContext and either complete or request another round.

The same admitted McpRequestContext instance, including verified responses and state, reaches lifecycle callbacks, the handler interceptor, and the operation handler.

Task input uses a durable flow

For a task in McpTaskStatus.INPUT_REQUIRED, the manager persists outstanding requests in the authoritative McpTask snapshot. The client submits any response subset through a fresh tasks/update, and McpTaskManager.updateTask(...) authorizes and applies it idempotently. See MCP Tasks.

The values in inputRequests are embedded client operations, not standalone JSON-RPC requests. Soklet places each method/params pair only inside the correlated input_required result and never writes a server-originated top-level request to the HTTP response or a request-scoped SSE stream. A top-level method-bearing server message is a notification without an id; a correlated response has an id and no method. After performing an embedded operation, the client sends a fresh POST to retry the original tool, prompt, or resource request.

Declare client requests

McpInputRequestDeclaration supports form and URL elicitation:

McpInputRequestDeclaration form = McpInputRequestDeclaration
  .fromElicitationForm(McpInputRequirement.CONDITIONAL);

McpInputRequestDeclaration url = McpInputRequestDeclaration
  .fromElicitationUrl(McpInputRequirement.REQUIRED);

Both use elicitation/create; each declaration derives the capability for its selected mode. Declarations are not an escape hatch for arbitrary client methods.

Declarations are final immutable Soklet values with private constructors. Use only the two named factories above, then inspect a declaration with getInputRequestType(), getJsonRpcMethod(), getCapabilities(), and getRequirement(). An emitted request is likewise created with McpInputRequest.fromDeclaration(declaration, params) and exposes getDeclaration(), getJsonRpcMethod(), and getParams(); there is no public constructor or record-style component accessor.

REQUIRED capabilities are checked after successful application admission and before execution on every call. CONDITIONAL capabilities are checked only if the handler emits that request. If several emitted requests lack capabilities, Soklet reports all missing capabilities before exposing request parameters, state, or protector behavior.

Attach declarations with inputRequestDeclarations(List) on an advanced tool builder; prompt and resource builders expose the same named operation. Alternatively, use typed @McpMayRequestInput(type=..., requirement=...) values in an operation annotation.

Return input_required

This tool asks for form input and carries a small JSON state machine between requests:

McpToolRegistration<McpJsonObject> continueTool = McpToolRegistration
  .withName("catalog.continue", Set.of(McpProtocolVersion.V2026_07_28))
  .jsonObjectArguments()
  .handler((requestContext, arguments, invocationFeatures) -> {
    if (requestContext.getFrameworkRequestState().isEmpty()) {
      return McpInputRequiredResult.withInputRequest(
        "approval", McpInputRequest.fromDeclaration(
          form,
          McpJsonObject.builder()
            .put("message", "Approve catalog access?")
            .put("mode", "form")
            .put("requestedSchema", McpJsonObject.builder()
              .put("type", "object")
              .put("properties", McpJsonObject.emptyInstance())
              .build())
            .build()
        ))
        .frameworkRequestState(McpJsonObject.builder()
          .put("phase", "waiting-for-approval")
          .build())
        .build();
    }

    McpJsonValue protectedState =
      requestContext.getFrameworkRequestState().orElseThrow();
    McpJsonValue approvalResponse = requestContext.getInputResponses()
      .find("approval")
      .orElseThrow();

    return McpCompleteResult.fromToolText(
      continueFrom(protectedState, approvalResponse)
    );
  })
  .inputRequestDeclarations(List.of(form))
  .requestStateMode(McpRequestStateMode.FRAMEWORK_PROTECTED)
  .build();

continueFrom(...) is application-owned: it must validate the protected phase, handle the elicitation action, and authorize any side effect before proceeding.

Input-request keys are exact application-assigned JSON member names and must be unique within one result. McpInputRequiredResult must contain at least one request or state value. It deliberately has no cache control.

McpInputResponses.find(key) returns the exact McpJsonValue. Overloads accepting Class<T> or TypeReference<T> use Soklet's closed MCP binding. asMap() exposes the immutable keyed collection. emptyInstance(), fromResponses(...), and builder() are useful when constructing application tests.

Elicitation content values may be strings, numbers (including decimals), booleans, or arrays of strings. A value such as 3.50 reaches your application as an exact BigDecimal through McpJsonNumber.getValue(), without integer or floating-point conversion. Numbers remain within the production JSON limits: 1,024 characters per number token and an absolute exponent magnitude of at most 10,000, including serialization preflight. Nulls, nested objects, and arrays containing non-string elements are outside this content union.

McpInputRequest.matchesInputResponse(...) checks the protocol union for both ordinary retries and durable task input. It does not enforce the requested form schema. Your application must still enforce an integer field, range, required field, and other application constraints before acting on the answer.

Typed lookup is conversion, not policy

Soklet validates the protocol union and preserves valid keyed responses, including unexpected keys. Your application must correlate each key with the request it issued, handle missing responses by re-requesting when appropriate, branch explicitly on elicitation accept/decline/cancel, and validate the exact accepted content. A successful typed conversion does not establish any of those facts.

Choose a request-state mode

Every capable operation declares one McpRequestStateMode:

ModeApplication suppliesRetry receivesOwnership
NONENo stateNo stateDefault
APPLICATION_PROTECTEDNonempty opaque stringExact string from getApplicationRequestState()Application protects, expires, and validates it
FRAMEWORK_PROTECTEDMcpJsonValueVerified JSON from getFrameworkRequestState()Soklet seals, authenticates, binds, and validates it

For application protection, call applicationRequestState(String). Soklet enforces a 65,536-byte UTF-8 bound but does not parse, encrypt, sign, expire, authorize, count rounds, or otherwise interpret the value.

For framework protection, call frameworkRequestState(McpJsonValue) and configure McpProtectionConfig on the server.

On an initial request, getApplicationRequestState() and getFrameworkRequestState() are both empty. On a retry, only the accessor for the operation's declared mode can be present. Applications receive Optional<String> or an Optional containing McpJsonValue directly; there is no state carrier to instantiate or cast. McpInputRequiredResult exposes the same typed accessors for tests and result inspection, while its two builder setters remain mutually exclusive and last-call-wins.

Scalar JSON leaves use McpJsonString.fromValue(String), McpJsonBoolean.fromValue(Boolean), and McpJsonNumber.fromValue(BigDecimal), paired respectively with McpJsonString.getValue(), McpJsonBoolean.getValue(), and McpJsonNumber.getValue(). Object and array values retain their named factories and builders.

JSON number equality and hashing use numeric value: 1, 1.0 and 1E+0 are equal and have the same hash code. This also applies to numeric leaves in objects and arrays. For ordinary JSON values, getValue() retains the supplied BigDecimal and its scale, including decimal answers such as 3.50.

Framework-protected state returns canonical numbers on a verified retry. Trailing decimal zeros are removed, and zero has scale zero: 100 returns as 1E+2, 1.50 as 1.5, and 0.0 as 0. The numeric value, JSON-tree equality and hash lookup survive the round trip; the original decimal scale and spelling do not. Use exact numeric conversions or compareTo for numbers. toPlainString() avoids exponent notation but does not recover the original scale. Store identifiers and exact decimal text as JSON strings, such as "5000" or "1.50".

Framework protection choices

Exactly one protection provider is selected:

McpProtectionKey active = McpProtectionKey.fromIdAndBytes(
  "2026-08",
  keyBytesFromSecureSecretStorage()
);

McpProtectionConfig production = McpProtectionConfig
  .withKeyring(McpProtectionKeyring.withActiveKey(active).build())
  .maximumRequestStateLifetime(Duration.ofMinutes(15))
  .maximumRequestStateRounds(10)
  .build();

The defaults are 65,536 encoded bytes, 49,152 decoded bytes, a 15-minute maximum lifetime, and 10 rounds. The builder validates positive size limits and decoded <= encoded; it does not guarantee that every continuation fits. These are maximum rejection limits. Decoded bytes include canonical framework metadata and application state; built-in encoded state also includes encryption-envelope and encoding overhead, including the key ID. Custom protectors may use different overhead. A valid configuration can still reject a particular input_required result during sealing with a sanitized JSON-RPC -32603 error, emitting no new state. Size limits using representative continuations, including request IDs and locale metadata; there is no universal minimum that guarantees arbitrary state fits.

The round limit counts emitted continuations, starting at one. A valid retry carrying the final allowed round can complete, but cannot emit further framework state. State can also expire while a valid retry's handler runs. Returning another framework input_required result in either case fails during sealing after the handler returns: a sanitized JSON-RPC -32603 internal error, with HTTP 200 when the response is still uncommitted. No new state is emitted and application side effects are not rolled back. State already expired or over-round when opened remains a 400 / -32602 error before handler entry.

getFrameworkRequestState() returns your JSON, without the framework's hidden round, issuance or expiry metadata. Include your own step counter or deadline when the handler needs to choose whether to complete, ask again or return a business error. Keep application deadlines within the configured lifetime; durable consumption/idempotency still belongs in application infrastructure. These application policies cannot make sealing atomic with a side effect or eliminate an expiry race. Application-protected handles instead follow your repository's round and expiry policy.

Request-body size and protected-state size are independent. Stable-parameter binding supports accepted requests under the configured MCP request-body limit: 10 MiB by default, configurable up to 16 MiB. It has a bounded 32 MiB canonical-parameter allowance because numeric spelling may grow during canonicalization; ordinary depth, node and scalar limits still apply. The 4 MiB response-output ceiling does not cap the digest input. The binding contains a fixed-size parameter digest, so large arguments do not become a large state envelope unless your application also includes them in its state JSON.

McpRequestStateProtector implementations receive McpRequestStateProtectionContext and must authenticate its exact associated data. McpRequestStateProtectionException reports a bounded Reason; do not leak secret material or raw attacker-controlled state in exceptions.

What framework state is bound to

Built-in state is bound to:

  • normalized endpoint path and protocol version;
  • JSON-RPC method;
  • the admitted authorization partition;
  • stable request parameters; and
  • lifetime, round number, and prior request ID.

The parameter digest omits only retry inputResponses, requestState, and transient progress/trace/baggage metadata. That lets a legitimate retry add responses without moving the state to another operation or authorization partition.

The first emission records round 1, issue/expiry time, and the emitting request ID. Re-emission preserves the original expiry, increments the round, and records the current ID. The immediate retry must use a different ID from the request that emitted that state.

This is not a server-side single-use store. Applications needing stronger replay prevention or workflow-consumption semantics must enforce them separately.

Continuation starts only after the client receives an input_required result and sends a fresh request. It does not transfer the previous handler, connection, or partially written response. If the producing instance fails before delivering that result, no continuation checkpoint reached the client. See Failures, Retries, And Durable Work for the cross-node failure and idempotency contract.

Durable application handles

For a durable APPLICATION_PROTECTED workflow, carry one high-entropy opaque handle in every participating retry and keep the authoritative state in an application repository. Do not infer workflow identity from the TCP connection, request worker, or JSON-RPC ID. Every application instance that may receive the next retry must reach the same durable repository, or an explicitly routed equivalent.

Bind each repository entry to the admitted tenant, principal, authorization partition, operation, and expiry required by the deployment. On a successful retry, atomically consume and replace the handle with compare-and-swap or an equivalent transaction. Missing, unknown, expired, replayed, wrong-tenant, and wrong-principal handles should collapse to one fixed client-safe failure without consuming another caller's valid state. Keep identity and other meaningful data out of the handle itself; use cryptographically secure random bytes and store those bindings server-side.

The executable application example with rotating handles example resumes a handle through a fresh workflow object over the same repository, interleaves two principals, rejects crossed handles without consumption, rotates accepted handles, and returns the public McpInputRequiredResult application-state form. Its in-memory repository is deliberately a process-local test double; production code must inject durable shared storage.

Fleet behavior and key rotation

Framework state can continue on another instance only when both instances use identical protection material and admission maps the retry to the same authorization partition. A matching key ID with different bytes is not equivalent.

Each server copies its initial McpProtectionKeyring and exposes live control through McpServer.getProtectionKeyringManager():

  1. stageVerificationKey(...) with the identical new key on every instance.
  2. Compare secret-free McpProtectionKeyringSnapshot and fingerprint version, profile, and value.
  3. activateStagedKey(...) everywhere.
  4. Wait at least the former key's maximum state lifetime and for sealing reservations to drain.
  5. removeVerificationKey(...) everywhere.

McpProtectionKeyringFingerprint uses diagnostic encoding v2 with protection profile soklet-mcp-protection-v1. It compares exact raw bytes for every active and verification-only key. Earlier v1 fingerprints could equate different keys that cannot decrypt each other's state, so they are not sufficient evidence of fleet convergence. Compare getVersion(), getProfile(), and getValue() together. Different fingerprint versions are incomparable; finish a software rollout before relying on matching v2 fingerprints to approve key rotation. This diagnostic change requires no secret rotation and changes neither sealed state nor trace correlation.

rotateActiveKey(...) is a retry-safe stage-and-activate convenience for one server. McpProtectionKeyInUseException means a verification key still has an outstanding sealing reservation. Key material is defensively copied and redacted; key IDs are non-secret ASCII HTTP tokens from 1 to 64 bytes, and key material must be at least 32 bytes from a cryptographically secure source.

Application responsibilities

Input request parameters and responses remain untrusted. Validate form values, URL workflow binding, authorization, and any external side effects in application code. State protection supplies integrity and binding; it does not decide whether the requested business action is safe.

Elicitation

For form mode, reject secret-bearing fields before constructing the request. At minimum, apply a reviewed policy to password, API-key/token, payment-card, and verification-code concepts; field spelling alone is not a universal semantic classifier. On retry:

  • re-request a necessary response when its key is missing;
  • treat decline and cancel as normal terminal branches, not malformed success;
  • for accept, allow only the expected field set and validate each exact value, length, character policy, and business rule; and
  • perform authorization again before any side effect.

For URL mode, use a server-owned HTTPS origin outside development. Put no username, user data, pre-authenticated bearer capability, or secret in userinfo, query, or fragment. Give the URL an opaque, single-purpose state handle stored server-side and bind that handle to the verified initiating user. Completion of the web flow does not replace MCP authentication or authorization, and credentials obtained out of band must not be copied into the MCP response.

Progress is suppressed while an unmet CONDITIONAL capability keeps the response uncommitted. A proxy can reach its idle timeout while waiting because the held request emits no response or progress bytes. When the capability is present, request-scoped SSE progress is available normally. Proxy disconnect is a cooperative cancelation signal; non-cooperative application code may remain accounted until it exits. See Progress and Subscriptions for invocation controls available during completing rounds.

Previous
Apps