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.EnumSet; 026import java.util.LinkedHashSet; 027import java.util.Objects; 028import java.util.Optional; 029import java.util.Set; 030 031import static java.util.Objects.requireNonNull; 032 033/** 034 * Immutable security policy and presentation hints for an MCP Apps resource. 035 * 036 * <p>Permissions request capabilities rather than grant them. Hosts enforce 037 * content security policy and may further restrict it; this value does not 038 * construct a browser sandbox or establish host acceptance of a domain hint. 039 * 040 * @author <a href="https://www.revetkn.com">Mark Allen</a> 041 */ 042@ThreadSafe 043public final class McpAppResourceMetadata { 044 @Nullable 045 private final ContentSecurityPolicy contentSecurityPolicy; 046 @NonNull 047 private final Set<@NonNull Permission> permissions; 048 @Nullable 049 private final String domain; 050 @Nullable 051 private final Boolean prefersBorder; 052 053 /** @return mutable builder with empty permissions and omitted hints */ 054 @NonNull 055 public static Builder builder() { 056 return new Builder(); 057 } 058 059 private McpAppResourceMetadata(@NonNull Builder builder) { 060 this.contentSecurityPolicy = builder.contentSecurityPolicy; 061 this.permissions = builder.permissions; 062 this.domain = builder.domain; 063 this.prefersBorder = builder.prefersBorder; 064 } 065 066 /** 067 * Returns the explicitly supplied content security policy. 068 * <p>An empty optional means CSP metadata is omitted, not that sandbox 069 * protection is disabled. MCP Apps hosts must still apply restrictive defaults. 070 * 071 * @return content security policy, or empty when omitted 072 */ 073 @NonNull 074 public Optional<@NonNull ContentSecurityPolicy> getContentSecurityPolicy() { 075 return Optional.ofNullable(this.contentSecurityPolicy); 076 } 077 078 /** @return immutable requested permissions in enum declaration order */ 079 @NonNull 080 public Set<@NonNull Permission> getPermissions() { 081 return this.permissions; 082 } 083 084 /** @return optional host-specific sandbox-origin hint */ 085 @NonNull 086 public Optional<@NonNull String> getDomain() { 087 return Optional.ofNullable(this.domain); 088 } 089 090 /** 091 * Returns the host presentation preference for a visible border and background. 092 * Omission leaves the presentation to the host; explicit false requests an 093 * unframed presentation. This hint does not configure CSS inside the App. 094 * 095 * @return border preference, preserving omission separately from false 096 */ 097 @NonNull 098 public Optional<@NonNull Boolean> getPrefersBorder() { 099 return Optional.ofNullable(this.prefersBorder); 100 } 101 102 /** @return whether policy, permissions, and optional hints match */ 103 @Override 104 public boolean equals(@Nullable Object other) { 105 if (this == other) 106 return true; 107 if (!(other instanceof McpAppResourceMetadata metadata)) 108 return false; 109 return Objects.equals(this.contentSecurityPolicy, metadata.contentSecurityPolicy) 110 && this.permissions.equals(metadata.permissions) 111 && Objects.equals(this.domain, metadata.domain) 112 && Objects.equals(this.prefersBorder, metadata.prefersBorder); 113 } 114 115 /** @return structural hash code */ 116 @Override 117 public int hashCode() { 118 return Objects.hash(this.contentSecurityPolicy, this.permissions, 119 this.domain, this.prefersBorder); 120 } 121 122 /** @return diagnostic rendering without policy or hint values */ 123 @Override 124 @NonNull 125 public String toString() { 126 return "McpAppResourceMetadata{contentSecurityPolicy=<redacted>, " 127 + "permissions=<redacted>, domain=<redacted>, prefersBorder=<redacted>}"; 128 } 129 130 /** 131 * Browser capabilities that an Apps resource may request from its host. 132 * 133 * <p>This domain is closed for 4.0. Adding a permission requires an explicit 134 * domain and API-evolution amendment; raw metadata cannot bypass this domain. 135 * 136 * <p> 137 * This is a closed domain; exhaustive switches are supported, and any new alternative requires an explicit reviewed contract amendment. 138 * 139 * @author <a href="https://www.revetkn.com">Mark Allen</a> 140 */ 141 public enum Permission { 142 /** Camera access. */ 143 CAMERA, 144 /** Microphone access. */ 145 MICROPHONE, 146 /** Geolocation access. */ 147 GEOLOCATION, 148 /** Clipboard write access. */ 149 CLIPBOARD_WRITE 150 } 151 152 /** 153 * Mutable builder for immutable Apps resource metadata. 154 * 155 * @author <a href="https://www.revetkn.com">Mark Allen</a> 156 */ 157 @NotThreadSafe 158 public static final class Builder { 159 @Nullable 160 private ContentSecurityPolicy contentSecurityPolicy; 161 @NonNull 162 private Set<@NonNull Permission> permissions = Collections.emptySet(); 163 @Nullable 164 private String domain; 165 @Nullable 166 private Boolean prefersBorder; 167 168 private Builder() {} 169 170 /** 171 * Sets a host-enforced content security policy. 172 * 173 * @param contentSecurityPolicy immutable policy 174 * @return this builder 175 */ 176 @NonNull 177 public Builder contentSecurityPolicy( 178 @NonNull ContentSecurityPolicy contentSecurityPolicy) { 179 this.contentSecurityPolicy = requireNonNull(contentSecurityPolicy); 180 return this; 181 } 182 183 /** 184 * Replaces requested permissions with an immutable ordered snapshot. 185 * 186 * @param permissions non-null permissions, possibly empty 187 * @return this builder 188 */ 189 @NonNull 190 public Builder permissions(@NonNull Set<@NonNull Permission> permissions) { 191 requireNonNull(permissions); 192 EnumSet<Permission> ordered = EnumSet.noneOf(Permission.class); 193 for (Permission permission : permissions) 194 ordered.add(requireNonNull(permission)); 195 this.permissions = Collections.unmodifiableSet(new LinkedHashSet<>(ordered)); 196 return this; 197 } 198 199 /** 200 * Sets a host-specific sandbox-origin hint without provisioning DNS. 201 * 202 * @param domain lowercase ASCII DNS name with LDH labels and no trailing dot 203 * @return this builder 204 * @throws IllegalArgumentException if the baseline DNS-name syntax is invalid 205 */ 206 @NonNull 207 public Builder domain(@NonNull String domain) { 208 this.domain = McpAppMetadataValidation.requireDomain(requireNonNull(domain)); 209 return this; 210 } 211 212 /** 213 * Requests a host-provided border and background when true, or an 214 * unframed presentation when false. Omitting this call lets the host decide. 215 * This presentation hint does not change the App's security policy. 216 * 217 * @param prefersBorder whether a border is preferred 218 * @return this builder 219 */ 220 @NonNull 221 public Builder prefersBorder(@NonNull Boolean prefersBorder) { 222 this.prefersBorder = requireNonNull(prefersBorder); 223 return this; 224 } 225 226 /** @return immutable resource metadata */ 227 @NonNull 228 public McpAppResourceMetadata build() { 229 return new McpAppResourceMetadata(this); 230 } 231 } 232 233 /** 234 * Immutable allowlists of canonical ASCII origins, not raw CSP header sources. 235 * 236 * <p>Empty collections allow no additional external connection, resource, or 237 * frame origins and retain same-origin-only document base URIs. Host enforcement 238 * may impose further restrictions. A wildcard is permitted only as one leftmost 239 * DNS label, such as {@code https://*.example.com}. 240 * 241 * @author <a href="https://www.revetkn.com">Mark Allen</a> 242 */ 243 @ThreadSafe 244 public static final class ContentSecurityPolicy { 245 @NonNull 246 private static final ContentSecurityPolicy DEFAULT_INSTANCE = builder().build(); 247 @NonNull 248 private final Set<@NonNull String> connectDomains; 249 @NonNull 250 private final Set<@NonNull String> resourceDomains; 251 @NonNull 252 private final Set<@NonNull String> frameDomains; 253 @NonNull 254 private final Set<@NonNull String> baseUriDomains; 255 256 /** 257 * Returns the immutable default policy, equivalent to {@code builder().build()}. 258 * <p>All four origin allowlists are empty: no additional external connection, 259 * resource, or frame origins are declared, and document base URIs remain 260 * same-origin-only. Enforcement belongs to the host, which may further restrict 261 * the policy. 262 * <p>Supplying this value explicitly emits CSP metadata. Leaving the policy 263 * unspecified omits that metadata, but still requires the host to apply 264 * restrictive defaults; omission does not disable sandbox protection. 265 * 266 * @return default content security policy 267 */ 268 @NonNull 269 public static ContentSecurityPolicy defaultInstance() { 270 return DEFAULT_INSTANCE; 271 } 272 273 /** @return mutable builder with empty origin allowlists */ 274 @NonNull 275 public static Builder builder() { 276 return new Builder(); 277 } 278 279 private ContentSecurityPolicy(@NonNull Builder builder) { 280 this.connectDomains = builder.connectDomains; 281 this.resourceDomains = builder.resourceDomains; 282 this.frameDomains = builder.frameDomains; 283 this.baseUriDomains = builder.baseUriDomains; 284 } 285 286 /** @return immutable connection origins in canonical ASCII order */ 287 @NonNull 288 public Set<@NonNull String> getConnectDomains() { 289 return this.connectDomains; 290 } 291 292 /** @return immutable asset origins in canonical ASCII order */ 293 @NonNull 294 public Set<@NonNull String> getResourceDomains() { 295 return this.resourceDomains; 296 } 297 298 /** @return immutable iframe origins in canonical ASCII order */ 299 @NonNull 300 public Set<@NonNull String> getFrameDomains() { 301 return this.frameDomains; 302 } 303 304 /** @return immutable document-base origins in canonical ASCII order */ 305 @NonNull 306 public Set<@NonNull String> getBaseUriDomains() { 307 return this.baseUriDomains; 308 } 309 310 /** @return whether all four origin allowlists match */ 311 @Override 312 public boolean equals(@Nullable Object other) { 313 if (this == other) 314 return true; 315 if (!(other instanceof ContentSecurityPolicy policy)) 316 return false; 317 return this.connectDomains.equals(policy.connectDomains) 318 && this.resourceDomains.equals(policy.resourceDomains) 319 && this.frameDomains.equals(policy.frameDomains) 320 && this.baseUriDomains.equals(policy.baseUriDomains); 321 } 322 323 /** @return structural hash code */ 324 @Override 325 public int hashCode() { 326 return Objects.hash(this.connectDomains, this.resourceDomains, 327 this.frameDomains, this.baseUriDomains); 328 } 329 330 /** @return diagnostic rendering without origin values */ 331 @Override 332 @NonNull 333 public String toString() { 334 return "ContentSecurityPolicy{connectDomains=<redacted>, " 335 + "resourceDomains=<redacted>, frameDomains=<redacted>, " 336 + "baseUriDomains=<redacted>}"; 337 } 338 339 /** 340 * Mutable builder for an immutable content security policy. 341 * 342 * @author <a href="https://www.revetkn.com">Mark Allen</a> 343 */ 344 @NotThreadSafe 345 public static final class Builder { 346 @NonNull 347 private Set<@NonNull String> connectDomains = Collections.emptySet(); 348 @NonNull 349 private Set<@NonNull String> resourceDomains = Collections.emptySet(); 350 @NonNull 351 private Set<@NonNull String> frameDomains = Collections.emptySet(); 352 @NonNull 353 private Set<@NonNull String> baseUriDomains = Collections.emptySet(); 354 355 private Builder() {} 356 357 /** 358 * Replaces connection origins with a validated, sorted snapshot. 359 * 360 * @param connectDomains canonical HTTPS/WSS origins, or HTTP/WS loopback origins 361 * @return this builder 362 * @throws IllegalArgumentException if any origin is invalid 363 */ 364 @NonNull 365 public Builder connectDomains(@NonNull Set<@NonNull String> connectDomains) { 366 this.connectDomains = McpAppMetadataValidation.immutableOrigins( 367 requireNonNull(connectDomains), true); 368 return this; 369 } 370 371 /** 372 * Replaces asset origins with a validated, sorted snapshot. 373 * 374 * @param resourceDomains canonical HTTPS origins, or HTTP loopback origins 375 * @return this builder 376 * @throws IllegalArgumentException if any origin is invalid 377 */ 378 @NonNull 379 public Builder resourceDomains(@NonNull Set<@NonNull String> resourceDomains) { 380 this.resourceDomains = McpAppMetadataValidation.immutableOrigins( 381 requireNonNull(resourceDomains), false); 382 return this; 383 } 384 385 /** 386 * Replaces iframe origins with a validated, sorted snapshot. 387 * 388 * @param frameDomains canonical HTTPS origins, or HTTP loopback origins 389 * @return this builder 390 * @throws IllegalArgumentException if any origin is invalid 391 */ 392 @NonNull 393 public Builder frameDomains(@NonNull Set<@NonNull String> frameDomains) { 394 this.frameDomains = McpAppMetadataValidation.immutableOrigins( 395 requireNonNull(frameDomains), false); 396 return this; 397 } 398 399 /** 400 * Replaces document-base origins with a validated, sorted snapshot. 401 * 402 * @param baseUriDomains canonical HTTPS origins, or HTTP loopback origins 403 * @return this builder 404 * @throws IllegalArgumentException if any origin is invalid 405 */ 406 @NonNull 407 public Builder baseUriDomains(@NonNull Set<@NonNull String> baseUriDomains) { 408 this.baseUriDomains = McpAppMetadataValidation.immutableOrigins( 409 requireNonNull(baseUriDomains), false); 410 return this; 411 } 412 413 /** @return immutable content security policy */ 414 @NonNull 415 public ContentSecurityPolicy build() { 416 return new ContentSecurityPolicy(this); 417 } 418 } 419 } 420}