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 com.soklet.internal.mcp.protocol.McpServerRuntimeBridge;
020import org.jspecify.annotations.NonNull;
021import org.jspecify.annotations.Nullable;
022
023import javax.annotation.concurrent.NotThreadSafe;
024import javax.annotation.concurrent.ThreadSafe;
025import java.util.ArrayList;
026import java.util.Collections;
027import java.util.LinkedHashMap;
028import java.util.List;
029import java.util.Map;
030import java.util.Objects;
031
032import static java.util.Objects.requireNonNull;
033
034/**
035 * Typed MCP admission rejection. Soklet owns envelope serialization and
036 * suppresses the body for notifications. Application response headers are
037 * validated by the builder and revalidated before transport. A
038 * {@link BearerAuthenticationChallenge} supplies a validated
039 * {@code WWW-Authenticate} value; the application owns token verification,
040 * protected-resource metadata, and scope policy. Unsafe,
041 * framework-owned, hop-by-hop, CORS, and obsolete transport-state headers
042 * fail during header construction without exposing custom names or values.
043 * <p>
044 * Header names must be ASCII HTTP tokens and unique ignoring case. Value lists
045 * must be nonempty; each value may be empty but must contain only visible ASCII
046 * or horizontal tabs. The fixed limits are 100 header fields and 65,536 bytes,
047 * counting each field as its name length plus value length plus four bytes.
048 * <p>
049 * Reserved names, ignoring case, are {@code Cache-Control}, {@code Connection},
050 * {@code Content-Encoding}, {@code Content-Length}, {@code Content-Type},
051 * {@code Keep-Alive}, {@code Proxy-Authenticate}, {@code Proxy-Authorization},
052 * {@code Proxy-Connection}, {@code TE}, {@code Trailer}, {@code Transfer-Encoding},
053 * {@code Upgrade}, {@code Retry-After}, {@code Mcp-Session-Id}, {@code Last-Event-ID},
054 * and every name beginning with {@code Access-Control-}.
055 *
056 * @author <a href="https://www.revetkn.com">Mark Allen</a>
057 */
058@ThreadSafe
059public final class McpAdmissionRejection {
060        private final int statusCode;
061        @NonNull
062        private final McpJsonRpcError jsonRpcError;
063        @NonNull
064        private final Map<@NonNull String, @NonNull List<@NonNull String>> headers;
065
066        /**
067         * Vends a rejection builder primed with an HTTP status and JSON-RPC error.
068         *
069         * @param statusCode HTTP status from 400 through 599
070         * @param jsonRpcError client-visible JSON-RPC error
071         * @return rejection builder
072         * @throws NullPointerException if {@code statusCode} or {@code jsonRpcError} is null
073         */
074        @NonNull
075        public static Builder withStatusCodeAndError(@NonNull Integer statusCode,
076                        @NonNull McpJsonRpcError jsonRpcError) {
077                return new Builder().statusCode(statusCode).jsonRpcError(jsonRpcError);
078        }
079
080        /**
081         * Vends a rejection builder using the challenge's recommended HTTP status
082         * and adding its rendered {@code WWW-Authenticate} value.
083         *
084         * @param bearerAuthenticationChallenge validated Bearer challenge
085         * @param jsonRpcError client-visible JSON-RPC error
086         * @return rejection builder
087         */
088        @NonNull
089        public static Builder withBearerAuthenticationChallengeAndError(
090                        @NonNull BearerAuthenticationChallenge bearerAuthenticationChallenge,
091                        @NonNull McpJsonRpcError jsonRpcError) {
092                BearerAuthenticationChallenge challenge =
093                                requireNonNull(bearerAuthenticationChallenge);
094                return withStatusCodeAndError(challenge.getRecommendedStatusCode(),
095                                jsonRpcError).addHeader("WWW-Authenticate",
096                                challenge.getHeaderValue());
097        }
098
099        private McpAdmissionRejection(@NonNull Builder builder) {
100                this.statusCode = builder.statusCode;
101                if (this.statusCode < 400 || this.statusCode > 599)
102                        throw new IllegalArgumentException(
103                                        "Admission rejection statusCode must be between 400 and 599");
104                this.jsonRpcError = requireNonNull(builder.jsonRpcError, "jsonRpcError");
105                Map<String, List<String>> copied = new LinkedHashMap<>();
106                builder.headers.forEach((name, values) -> copied.put(
107                                requireNonNull(name), List.copyOf(requireNonNull(values))));
108                McpServerRuntimeBridge.validateAdmissionRejectionHeaders(copied);
109                this.headers = Collections.unmodifiableMap(copied);
110        }
111
112        /** @return rejection HTTP status */
113        @NonNull
114        public Integer getStatusCode() {
115                return this.statusCode;
116        }
117
118        /** @return client-visible JSON-RPC error */
119        @NonNull
120        public McpJsonRpcError getJsonRpcError() {
121                return this.jsonRpcError;
122        }
123
124        /**
125         * Returns the immutable snapshot of validated application response headers.
126         * Original name spelling, insertion order and value order are preserved.
127         *
128         * @return immutable application response headers
129         */
130        @NonNull
131        public Map<@NonNull String, @NonNull List<@NonNull String>> getHeaders() {
132                return this.headers;
133        }
134
135        /** @return whether every admission-rejection property is structurally equal */
136        @Override
137        public boolean equals(@Nullable Object other) {
138                if (this == other)
139                        return true;
140                if (!(other instanceof McpAdmissionRejection rejection))
141                        return false;
142                return this.statusCode == rejection.statusCode
143                                && this.jsonRpcError.equals(rejection.jsonRpcError)
144                                && this.headers.equals(rejection.headers);
145        }
146
147        /** @return structural admission-rejection hash code */
148        @Override
149        public int hashCode() {
150                return Objects.hash(this.statusCode, this.jsonRpcError, this.headers);
151        }
152
153        /**
154         * Mutable builder for an immutable {@link McpAdmissionRejection}.
155         *
156         * @author <a href="https://www.revetkn.com">Mark Allen</a>
157         */
158        @NotThreadSafe
159        public static final class Builder {
160                private int statusCode;
161                @Nullable
162                private McpJsonRpcError jsonRpcError;
163                @NonNull
164                private Map<@NonNull String, @NonNull List<@NonNull String>> headers =
165                                new LinkedHashMap<>();
166
167                private Builder() {
168                }
169
170                /**
171                 * @param statusCode HTTP status from 400 through 599
172                 * @return this builder
173                 * @throws NullPointerException if {@code statusCode} is null
174                 */
175                @NonNull
176                public Builder statusCode(@NonNull Integer statusCode) {
177                        this.statusCode = requireNonNull(statusCode);
178                        return this;
179                }
180
181                /** @param jsonRpcError client-visible error @return this builder */
182                @NonNull
183                public Builder jsonRpcError(@NonNull McpJsonRpcError jsonRpcError) {
184                        this.jsonRpcError = requireNonNull(jsonRpcError);
185                        return this;
186                }
187
188                /**
189                 * Replaces the application response headers with a validated snapshot.
190                 * Passing an empty map clears all application response headers. Invalid
191                 * replacement headers leave this builder's previous headers intact.
192                 *
193                 * @see McpAdmissionRejection for header safety rules and fixed bounds
194                 *
195                 * @param headers application response headers
196                 * @return this builder
197                 * @throws NullPointerException if the map, a name, a value list, or a value is null
198                 * @throws IllegalArgumentException if the headers violate the safety rules or fixed bounds
199                 */
200                @NonNull
201                public Builder headers(
202                                @NonNull Map<@NonNull String, ? extends @NonNull List<@NonNull String>> headers) {
203                        requireNonNull(headers);
204                        McpServerRuntimeBridge.validateAdmissionRejectionHeaders(headers);
205                        Map<String, List<String>> copied = new LinkedHashMap<>();
206                        headers.forEach((name, values) -> {
207                                String checkedName = McpContentValueSupport.requireWellFormedString(name);
208                                List<String> copiedValues = new ArrayList<>();
209                                requireNonNull(values).forEach(
210                                                value -> copiedValues.add(McpContentValueSupport.requireWellFormedString(value)));
211                                copied.put(checkedName, copiedValues);
212                        });
213                        McpServerRuntimeBridge.validateAdmissionRejectionHeaders(copied);
214                        this.headers = copied;
215                        return this;
216                }
217
218                /**
219                 * Adds a validated application response header. Reusing the exact name
220                 * appends a value; a different case spelling of an existing name is rejected.
221                 * Invalid additions leave this builder's previous headers intact.
222                 *
223                 * @see McpAdmissionRejection for header safety rules and fixed bounds
224                 *
225                 * @param name header name
226                 * @param value header value
227                 * @return this builder
228                 * @throws NullPointerException if the name or value is null
229                 * @throws IllegalArgumentException if the addition violates the safety rules or fixed bounds
230                 */
231                @NonNull
232                public Builder addHeader(@NonNull String name, @NonNull String value) {
233                        McpContentValueSupport.requireWellFormedString(name);
234                        McpContentValueSupport.requireWellFormedString(value);
235                        Map<String, List<String>> updated = new LinkedHashMap<>(this.headers);
236                        List<String> values = new ArrayList<>(updated.getOrDefault(name, List.of()));
237                        values.add(value);
238                        updated.put(name, values);
239                        McpServerRuntimeBridge.validateAdmissionRejectionHeaders(updated);
240                        this.headers = updated;
241                        return this;
242                }
243
244                /** @return immutable rejection */
245                @NonNull
246                public McpAdmissionRejection build() {
247                        return new McpAdmissionRejection(this);
248                }
249        }
250}