001/*
002 * Copyright 2022-2026 Revetware LLC.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package com.soklet;
018
019import org.jspecify.annotations.NonNull;
020import org.jspecify.annotations.Nullable;
021
022import javax.annotation.concurrent.NotThreadSafe;
023import javax.annotation.concurrent.ThreadSafe;
024import java.util.Collections;
025import java.util.LinkedHashMap;
026import java.util.Map;
027import java.util.Objects;
028import java.util.Optional;
029
030import static java.util.Objects.requireNonNull;
031import static com.soklet.internal.mcp.protocol.McpApplicationMetadata.requireApplicationMetadata;
032
033/**
034 * Immutable MCP result indicating that an operation needs additional client
035 * input before it can complete.
036 *
037 * <p>At least one input request or request-state value is required. Input
038 * requests retain their insertion order and their keys are unique within the
039 * result. Input-required results intentionally expose no cache controls.
040 *
041 * <p>For a durable {@link McpRequestStateMode#APPLICATION_PROTECTED}
042 * continuation, an application can persist the continuation in its own
043 * repository and return only an opaque handle as application request state.
044 * The client must carry the current handle on every retry, including retries
045 * over a new connection. Soklet transports that string; it does not store,
046 * rotate, protect, authorize, or recover the referenced application record.
047 *
048 * @author <a href="https://www.revetkn.com">Mark Allen</a>
049 */
050@ThreadSafe
051public final class McpInputRequiredResult implements McpOperationResult {
052        @NonNull
053        private final Map<@NonNull String, @NonNull McpInputRequest> inputRequests;
054        @Nullable
055        private final McpJsonValue frameworkRequestState;
056        @Nullable
057        private final String applicationRequestState;
058        @NonNull
059        private final McpJsonObject metadata;
060
061        /**
062         * Vends a builder containing its required first input request.
063         *
064         * @param key input-response correlation key
065         * @param inputRequest server-initiated request
066         * @return input-required-result builder
067         * @throws NullPointerException if an argument is null
068         */
069        @NonNull
070        public static Builder withInputRequest(@NonNull String key,
071                        @NonNull McpInputRequest inputRequest) {
072                return new Builder().addInputRequest(key, inputRequest);
073        }
074
075        /**
076         * Vends a builder containing application JSON for Soklet to protect as
077         * request state.
078         *
079         * @param frameworkRequestState application-defined JSON state
080         * @return input-required-result builder
081         * @throws NullPointerException if {@code frameworkRequestState} is null
082         */
083        @NonNull
084        public static Builder withFrameworkRequestState(
085                        @NonNull McpJsonValue frameworkRequestState) {
086                return new Builder().frameworkRequestState(frameworkRequestState);
087        }
088
089        /**
090         * Vends a builder containing opaque request state protected by the
091         * application.
092         *
093         * @param applicationRequestState nonempty opaque application-protected state
094         * @return input-required-result builder
095         * @throws NullPointerException if {@code applicationRequestState} is null
096         * @throws IllegalArgumentException if {@code applicationRequestState} is empty or contains malformed UTF-16
097         */
098        @NonNull
099        public static Builder withApplicationRequestState(
100                        @NonNull String applicationRequestState) {
101                return new Builder().applicationRequestState(applicationRequestState);
102        }
103
104        private McpInputRequiredResult(@NonNull Builder builder) {
105                this.inputRequests = Collections.unmodifiableMap(
106                                new LinkedHashMap<>(builder.inputRequests));
107                this.frameworkRequestState = builder.frameworkRequestState;
108                this.applicationRequestState = builder.applicationRequestState;
109                this.metadata = requireApplicationMetadata(builder.metadata);
110        }
111
112        /**
113         * Returns the input requests in insertion order.
114         *
115         * @return immutable input-request map
116         */
117        @NonNull
118        public Map<@NonNull String, @NonNull McpInputRequest> getInputRequests() {
119                return this.inputRequests;
120        }
121
122        /**
123         * Returns application-defined JSON for Soklet to protect as request state.
124         *
125         * <p>This inspection value retains the supplied representation. After
126         * protection and a verified retry, numeric leaves returned by
127         * {@link McpRequestContext#getFrameworkRequestState()} use the canonical
128         * numeric form and do not retain the original decimal scale.
129         *
130         * @return framework-protected request-state value, or empty when absent
131         */
132        @NonNull
133        public Optional<@NonNull McpJsonValue> getFrameworkRequestState() {
134                return Optional.ofNullable(this.frameworkRequestState);
135        }
136
137        /**
138         * Returns opaque request state whose protection is application-owned.
139         *
140         * <p>The value may be a handle into an application-owned durable
141         * repository. Its presence does not imply that Soklet has validated the
142         * handle's integrity, expiry, duplicate-use status, or binding to the admitted
143         * principal or authorization context.
144         *
145         * @return application-protected request-state value, or empty when absent
146         */
147        @NonNull
148        public Optional<@NonNull String> getApplicationRequestState() {
149                return Optional.ofNullable(this.applicationRequestState);
150        }
151
152        /**
153         * Returns application-supplied protocol extension metadata.
154         *
155         * @return immutable result metadata
156         */
157        @NonNull
158        public McpJsonObject getMetadata() {
159                return this.metadata;
160        }
161
162        /** @return whether every input-required result property is structurally equal */
163        @Override
164        public boolean equals(@Nullable Object other) {
165                if (this == other)
166                        return true;
167                if (!(other instanceof McpInputRequiredResult result))
168                        return false;
169                return this.inputRequests.equals(result.inputRequests)
170                                && Objects.equals(this.frameworkRequestState,
171                                                result.frameworkRequestState)
172                                && Objects.equals(this.applicationRequestState,
173                                                result.applicationRequestState)
174                                && this.metadata.equals(result.metadata);
175        }
176
177        /** @return structural input-required result hash code */
178        @Override
179        public int hashCode() {
180                return Objects.hash(this.inputRequests, this.frameworkRequestState,
181                                this.applicationRequestState, this.metadata);
182        }
183
184        /**
185         * Mutable builder for immutable {@link McpInputRequiredResult} values.
186         *
187         * @author <a href="https://www.revetkn.com">Mark Allen</a>
188         */
189        @NotThreadSafe
190        public static final class Builder {
191                @NonNull
192                private final Map<@NonNull String, @NonNull McpInputRequest>
193                                inputRequests = new LinkedHashMap<>();
194                @Nullable
195                private McpJsonValue frameworkRequestState;
196                @Nullable
197                private String applicationRequestState;
198                @NonNull
199                private McpJsonObject metadata = McpJsonObject.emptyInstance();
200
201                private Builder() {
202                }
203
204                /**
205                 * Appends one uniquely keyed input request.
206                 *
207                 * <p>Keys are exact server-assigned JSON object member names; empty and
208                 * whitespace-only keys remain valid MCP values. Reusing a key fails
209                 * without replacing the original request.
210                 *
211                 * @param key input-response correlation key
212                 * @param inputRequest server-initiated request
213                 * @return this builder
214                 * @throws NullPointerException if an argument is null
215                 * @throws IllegalArgumentException if the key is already present or contains malformed UTF-16
216                 */
217                @NonNull
218                public Builder addInputRequest(@NonNull String key,
219                                @NonNull McpInputRequest inputRequest) {
220                        McpContentValueSupport.requireWellFormedString(requireNonNull(key));
221                        requireNonNull(inputRequest);
222                        if (this.inputRequests.containsKey(key))
223                                throw new IllegalArgumentException(
224                                                "Input-request keys must be unique within a result.");
225                        this.inputRequests.put(key, inputRequest);
226                        return this;
227                }
228
229                /**
230                 * Supplies application JSON for Soklet to protect as opaque request
231                 * state on the wire.
232                 *
233                 * <p>This replaces any request state supplied by an earlier builder
234                 * call.
235                 *
236                 * <p>Protection and its configured size, round, and expiry checks run
237                 * after the handler returns. Re-emission at the maximum round or after
238                 * the original expiry fails as a sanitized internal error; building this
239                 * value does not guarantee that it can be sealed. Application side
240                 * effects are not rolled back. Returning a complete result on a valid
241                 * final-round retry does not require another state emission.
242                 *
243                 * @param frameworkRequestState application-defined JSON state
244                 * @return this builder
245                 * @throws NullPointerException if {@code frameworkRequestState} is null
246                 */
247                @NonNull
248                public Builder frameworkRequestState(
249                                @NonNull McpJsonValue frameworkRequestState) {
250                        McpJsonValue validatedState = requireNonNull(frameworkRequestState);
251                        this.frameworkRequestState = validatedState;
252                        this.applicationRequestState = null;
253                        return this;
254                }
255
256                /**
257                 * Supplies opaque request state protected by the application.
258                 *
259                 * <p>This replaces any request state supplied by an earlier builder
260                 * call.
261                 *
262                 * <p>For durable continuation, persist the state before returning the
263                 * result, use an unguessable handle, bind the repository record to the
264                 * admitted security context, and consume or rotate it atomically when
265                 * the application's duplicate-use policy requires that behavior. The client
266                 * must return the handle on each retry; Soklet provides no repository
267                 * for it.
268                 *
269                 * @param applicationRequestState nonempty opaque application-protected state
270                 * @return this builder
271                 * @throws NullPointerException if {@code applicationRequestState} is null
272                 * @throws IllegalArgumentException if {@code applicationRequestState} is empty or contains malformed UTF-16
273                 */
274                @NonNull
275                public Builder applicationRequestState(
276                                @NonNull String applicationRequestState) {
277                        String validatedState = McpContentValueSupport.requireWellFormedString(requireNonNull(applicationRequestState));
278                        if (validatedState.isEmpty())
279                                throw new IllegalArgumentException(
280                                                "Application request state must not be empty.");
281                        this.applicationRequestState = validatedState;
282                        this.frameworkRequestState = null;
283                        return this;
284                }
285
286                /**
287                 * Supplies protocol extension metadata.
288                 *
289                 * <p>This replaces metadata supplied by an earlier builder call.
290                 *
291                 * @param metadata immutable result metadata
292                 * @return this builder
293                 * @throws NullPointerException if {@code metadata} is null
294                 */
295                @NonNull
296                public Builder metadata(@NonNull McpJsonObject metadata) {
297                        this.metadata = requireNonNull(metadata);
298                        return this;
299                }
300
301                /**
302                 * Builds an immutable input-required result.
303                 *
304                 * @return immutable input-required result
305                 * @throws IllegalArgumentException if application metadata uses a reserved framework key
306                 */
307                @NonNull
308                public McpInputRequiredResult build() {
309                        return new McpInputRequiredResult(this);
310                }
311        }
312}