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}