001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.rest.server.mcp.v20260728; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.net.*; 022import java.util.*; 023 024import org.apache.juneau.rest.server.auth.TokenValidator; 025import org.apache.juneau.rest.server.auth.oauth.OAuthFilter; 026 027/** 028 * Binding-owned OAuth 2.1 resource-server (RS) configuration for a {@code 2026-07-28} MCP endpoint — the 029 * authorization-hardening baseline. 030 * 031 * <p> 032 * <b>Off by default.</b> {@link #isEnabled()} is <jk>false</jk> unless {@link #setEnabled(boolean) enabled}, so an 033 * existing MCP endpoint that does not opt in behaves exactly as before (no bearer requirement, no well-known route). 034 * When enabled, the endpoint: 035 * <ul> 036 * <li>Requires a valid RFC 6750 bearer token on the MCP POST endpoint, establishing an authenticated principal. 037 * <li>Serves an RFC 9728 Protected Resource Metadata document from {@code .well-known/oauth-protected-resource}. 038 * <li>Answers a missing/invalid token with {@code 401} + a {@code WWW-Authenticate: Bearer ...} challenge carrying the 039 * {@code resource_metadata} pointer. 040 * <li>Enforces RFC 8707 audience matching against {@link #getResource() the resource identifier}. 041 * </ul> 042 * 043 * <p> 044 * <b>Mutable during setup, effectively immutable once published</b> — the same builder-less mutable-setup idiom as 045 * {@link McpMrtrConfig} / {@link McpCacheConfig}: configure it fully via the {@code createMcpOptions()} / 046 * {@code getMcpOptions()} override, then never mutate it afterward (a request-time reader sees it with no 047 * synchronization). 048 * 049 * <h5 class='section'>Token-validation mode:</h5> 050 * <p> 051 * The validation mode (RFC 7662 introspection vs JWKS/issuer JWT verification) is selected by which 052 * {@link TokenValidator} is supplied to {@link #setTokenValidator(TokenValidator)}: 053 * {@code OAuthIntrospectionValidator} (from {@code juneau-rest-server-auth-oauth}) for opaque tokens, or 054 * {@code JwtTokenValidator} (from {@code juneau-rest-server-auth-jwt}) for JWT access tokens. 055 * 056 * @since 10.0.0 057 */ 058@SuppressWarnings({ 059 "java:S115", // Constants use UPPER_snakeCase convention (e.g., MSG_scopeMustNotBeNullOrBlank) 060 "java:S1192" // Duplicated validation-message literals read more clearly inline than as constants. 061}) 062public class McpResourceServerConfig { 063 064 /** Default challenge realm. */ 065 public static final String DEFAULT_REALM = "mcp"; 066 067 /** Default advertised/accepted bearer method. */ 068 public static final String DEFAULT_BEARER_METHOD = "header"; 069 070 private boolean enabled; 071 private URI resource; 072 private String audience; 073 private String realm = DEFAULT_REALM; 074 private boolean requireAudienceClaim = true; 075 private TokenValidator tokenValidator; 076 private final List<URI> authorizationServers = new ArrayList<>(); 077 private final Set<String> scopesSupported = new LinkedHashSet<>(); 078 private final Set<String> requiredScopes = new LinkedHashSet<>(); 079 private final Set<String> bearerMethodsSupported = new LinkedHashSet<>(List.of(DEFAULT_BEARER_METHOD)); 080 081 /** Wildcard operation-name key: an entry keyed on this applies to every operation of a given JSON-RPC method. */ 082 static final String OPERATION_WILDCARD = "*"; 083 084 private final Map<String,Set<String>> operationScopes = new LinkedHashMap<>(); 085 private McpOperationScopeResolver operationScopeResolver; 086 087 @SuppressWarnings({ 088 "java:S3077" // OAuthFilter is an effectively-immutable, thread-safe holder; volatile publication of the memoized instance is sufficient. 089 }) 090 private volatile OAuthFilter oauthFilter; 091 092 /** 093 * Whether RS authentication is enabled for this binding. 094 * 095 * @return <jk>true</jk> if enabled. Defaults to <jk>false</jk>. 096 */ 097 public boolean isEnabled() { 098 return enabled; 099 } 100 101 /** 102 * Enables or disables RS authentication. 103 * 104 * @param value The new value. 105 * @return This object (for method chaining). 106 */ 107 public McpResourceServerConfig setEnabled(boolean value) { 108 enabled = value; 109 return this; 110 } 111 112 /** 113 * This server's canonical resource identifier (RFC 9728 {@code resource} / RFC 8707 audience default). 114 * 115 * @return The resource identifier, or <jk>null</jk> if not set. 116 */ 117 public URI getResource() { 118 return resource; 119 } 120 121 /** 122 * Sets the canonical resource identifier. 123 * 124 * @param value The new value. Can be <jk>null</jk> to unset (but is required when {@link #isEnabled() enabled}). 125 * @return This object (for method chaining). 126 */ 127 public McpResourceServerConfig setResource(URI value) { 128 resource = value; 129 return this; 130 } 131 132 /** 133 * The RFC 8707 audience this server accepts. 134 * 135 * @return The explicit audience, or (when unset) {@link #getResource() the resource identifier} as a string, or 136 * <jk>null</jk> if neither is set. 137 */ 138 public String getAudience() { 139 if (audience != null) 140 return audience; 141 return resource == null ? null : resource.toString(); 142 } 143 144 /** 145 * Sets an explicit RFC 8707 audience, overriding the {@link #getResource() resource-identifier} default. 146 * 147 * @param value The new value. Can be <jk>null</jk> to fall back to the resource identifier. 148 * @return This object (for method chaining). 149 */ 150 public McpResourceServerConfig setAudience(String value) { 151 audience = value; 152 return this; 153 } 154 155 /** 156 * The {@code WWW-Authenticate: Bearer realm="..."} challenge realm. 157 * 158 * @return The realm. Never <jk>null</jk>; defaults to {@value #DEFAULT_REALM}. 159 */ 160 public String getRealm() { 161 return realm; 162 } 163 164 /** 165 * Sets the challenge realm. 166 * 167 * @param value The new value. Must not be <jk>null</jk> or blank. 168 * @return This object (for method chaining). 169 * @throws IllegalArgumentException If {@code value} is <jk>null</jk> or blank. 170 */ 171 public McpResourceServerConfig setRealm(String value) { 172 if (ib(value)) 173 throw iaex("realm must not be null or blank"); 174 realm = value; 175 return this; 176 } 177 178 /** 179 * Whether a validated token must expose audience claims for RFC 8707 audience enforcement to apply. 180 * 181 * <p> 182 * When <jk>true</jk> (the default), a validator that returns a bare {@link java.security.Principal} carrying no 183 * claims is <b>rejected</b> ({@code 401 invalid_token}) rather than silently passing the confused-deputy defense. 184 * Set to <jk>false</jk> only when the {@link #getTokenValidator() token validator} itself enforces the audience 185 * internally (e.g. a {@code JwtTokenValidator} configured to verify {@code aud}) so a claims-less principal is a 186 * deliberate, trusted signal — pairs with introspection-mode validators that legitimately omit {@code aud}. 187 * 188 * @return <jk>true</jk> if a claims-less principal is rejected (the default). 189 */ 190 public boolean isRequireAudienceClaim() { 191 return requireAudienceClaim; 192 } 193 194 /** 195 * Sets whether a validated token must expose audience claims for audience enforcement to apply. 196 * 197 * @param value The new value. Defaults to <jk>true</jk> (fail-closed). 198 * @return This object (for method chaining). 199 */ 200 public McpResourceServerConfig setRequireAudienceClaim(boolean value) { 201 requireAudienceClaim = value; 202 return this; 203 } 204 205 /** 206 * The token validator that verifies presented bearer tokens. 207 * 208 * @return The validator, or <jk>null</jk> if not set (required when {@link #isEnabled() enabled}). 209 */ 210 public TokenValidator getTokenValidator() { 211 return tokenValidator; 212 } 213 214 /** 215 * Sets the token validator (selects introspection vs JWT validation mode). 216 * 217 * @param value The new value. Can be <jk>null</jk> to unset (but is required when {@link #isEnabled() enabled}). 218 * @return This object (for method chaining). 219 */ 220 public McpResourceServerConfig setTokenValidator(TokenValidator value) { 221 tokenValidator = value; 222 return this; 223 } 224 225 /** 226 * The advertised {@code authorization_servers} (RFC 9728). 227 * 228 * @return A live, mutable list. Never <jk>null</jk>. 229 */ 230 public List<URI> getAuthorizationServers() { 231 return authorizationServers; 232 } 233 234 /** 235 * Adds an authorization-server issuer URI. 236 * 237 * @param value The issuer URI. Must not be <jk>null</jk>. 238 * @return This object (for method chaining). 239 * @throws IllegalArgumentException If {@code value} is <jk>null</jk>. 240 */ 241 public McpResourceServerConfig addAuthorizationServer(URI value) { 242 if (value == null) 243 throw iaex("authorizationServer must not be null"); 244 authorizationServers.add(value); 245 return this; 246 } 247 248 /** 249 * The advertised {@code scopes_supported} (RFC 9728). 250 * 251 * @return A live, mutable set. Never <jk>null</jk>. 252 */ 253 public Set<String> getScopesSupported() { 254 return scopesSupported; 255 } 256 257 /** 258 * Adds an advertised supported scope. 259 * 260 * @param value The scope. Must not be <jk>null</jk> or blank. 261 * @return This object (for method chaining). 262 * @throws IllegalArgumentException If {@code value} is <jk>null</jk> or blank. 263 */ 264 public McpResourceServerConfig addScopeSupported(String value) { 265 if (ib(value)) 266 throw iaex("scope must not be null or blank"); 267 scopesSupported.add(value); 268 return this; 269 } 270 271 /** 272 * The scopes a token must carry to access the MCP endpoint (baseline insufficient-scope enforcement). 273 * 274 * @return A live, mutable set. Never <jk>null</jk>; empty means no scope is required beyond a valid token. 275 */ 276 public Set<String> getRequiredScopes() { 277 return requiredScopes; 278 } 279 280 /** 281 * Adds a required scope (also advertised as supported). 282 * 283 * @param value The scope. Must not be <jk>null</jk> or blank. 284 * @return This object (for method chaining). 285 * @throws IllegalArgumentException If {@code value} is <jk>null</jk> or blank. 286 */ 287 public McpResourceServerConfig addRequiredScope(String value) { 288 if (ib(value)) 289 throw iaex("scope must not be null or blank"); 290 requiredScopes.add(value); 291 scopesSupported.add(value); 292 return this; 293 } 294 295 /** 296 * The advertised {@code bearer_methods_supported} (RFC 9728 / RFC 6750). 297 * 298 * @return A live, mutable set. Never <jk>null</jk>; defaults to {@code [header]}. 299 */ 300 public Set<String> getBearerMethodsSupported() { 301 return bearerMethodsSupported; 302 } 303 304 /** 305 * Registers the scopes required to invoke a specific MCP operation (SEP-2350 per-operation step-up). 306 * 307 * <p> 308 * Populates the default static per-operation scope map consulted by {@link #requiredScopesFor(McpOperationContext)} 309 * when no explicit {@link #setOperationScopeResolver(McpOperationScopeResolver) resolver} is set. A {@code null} or 310 * blank {@code name} registers a method-wide entry matching every operation of that JSON-RPC {@code method} (e.g. 311 * every {@code tools/call} regardless of tool name); a specific {@code name} takes precedence over the method-wide 312 * entry. Each supplied scope is also advertised as {@link #addScopeSupported(String) supported}. 313 * 314 * @param method The JSON-RPC method (e.g. {@code tools/call}). Must not be <jk>null</jk> or blank. 315 * @param name The operation name (tool/prompt name or resource URI), or <jk>null</jk>/blank for a method-wide entry. 316 * @param scopes The scopes required to invoke the operation. Must not be <jk>null</jk>, empty, or contain a 317 * <jk>null</jk>/blank element. 318 * @return This object (for method chaining). 319 * @throws IllegalArgumentException If {@code method} is <jk>null</jk>/blank or {@code scopes} is invalid. 320 */ 321 public McpResourceServerConfig addOperationScope(String method, String name, String... scopes) { 322 if (ib(method)) 323 throw iaex("method must not be null or blank"); 324 if (scopes == null || scopes.length == 0) 325 throw iaex("scopes must not be null or empty"); 326 // Validate the whole scopes array BEFORE mutating scopesSupported, so a throw on a later blank element never 327 // leaves earlier scopes half-advertised. 328 var set = new LinkedHashSet<String>(); 329 for (var s : scopes) { 330 if (ib(s)) 331 throw iaex("scope must not be null or blank"); 332 set.add(s); 333 } 334 scopesSupported.addAll(set); 335 operationScopes.put(operationKey(method, name == null || name.isBlank() ? OPERATION_WILDCARD : name), set); 336 return this; 337 } 338 339 /** 340 * The dynamic per-operation scope resolver, if one was installed. 341 * 342 * @return The resolver, or <jk>null</jk> to use the default static {@link #addOperationScope map}. 343 */ 344 public McpOperationScopeResolver getOperationScopeResolver() { 345 return operationScopeResolver; 346 } 347 348 /** 349 * Installs a dynamic per-operation scope resolver, overriding the default static {@link #addOperationScope map}. 350 * 351 * <p> 352 * The seam for a future dynamic resolver; not built for 10.0. When set, it fully replaces the 353 * static map for {@link #requiredScopesFor(McpOperationContext)}. 354 * 355 * @param value The resolver, or <jk>null</jk> to revert to the static map. 356 * @return This object (for method chaining). 357 */ 358 public McpResourceServerConfig setOperationScopeResolver(McpOperationScopeResolver value) { 359 operationScopeResolver = value; 360 return this; 361 } 362 363 /** 364 * Resolves the scopes required to invoke the given operation. 365 * 366 * <p> 367 * Consults the {@link #setOperationScopeResolver(McpOperationScopeResolver) resolver} when installed; otherwise the 368 * static {@link #addOperationScope map}, preferring a {@link McpOperationContext#name() name}-specific entry over the 369 * method-wide {@value #OPERATION_WILDCARD} entry. An empty return means "no per-operation requirement beyond the 370 * endpoint-wide {@link #getRequiredScopes() baseline}". 371 * 372 * @param ctx The resolved operation. Must not be <jk>null</jk>. 373 * @return The required scopes (never <jk>null</jk>; possibly empty). 374 */ 375 public Set<String> requiredScopesFor(McpOperationContext ctx) { 376 if (ctx == null) 377 throw iaex("ctx must not be null"); 378 if (operationScopeResolver != null) { 379 var s = operationScopeResolver.requiredScopesFor(ctx); 380 return s == null ? Set.of() : s; 381 } 382 var name = ctx.name(); 383 if (name != null) { 384 var s = operationScopes.get(operationKey(ctx.method(), name)); 385 if (s != null) 386 return Collections.unmodifiableSet(s); 387 } 388 var s = operationScopes.get(operationKey(ctx.method(), OPERATION_WILDCARD)); 389 return s == null ? Set.of() : Collections.unmodifiableSet(s); 390 } 391 392 private static String operationKey(String method, String name) { 393 return method + '\u0000' + name; 394 } 395 396 /** 397 * Validates that this config is internally consistent for the {@link #isEnabled() enabled} state. 398 * 399 * <p> 400 * A no-op when disabled. When enabled, requires a resource identifier (absolute, with scheme + authority) and a 401 * token validator. 402 * 403 * @throws IllegalStateException If enabled but misconfigured. 404 */ 405 public void validateEnabled() { 406 if (!enabled) 407 return; 408 if (resource == null) 409 throw isex("McpResourceServerConfig is enabled but no resource identifier was configured"); 410 if (resource.getScheme() == null || resource.getRawAuthority() == null) 411 throw isex("McpResourceServerConfig resource %s must be an absolute URI with a scheme and authority", resource); 412 if (tokenValidator == null) 413 throw isex("McpResourceServerConfig is enabled but no TokenValidator was configured"); 414 } 415 416 /** 417 * Returns this config's memoized {@link OAuthFilter}. 418 * 419 * <p> 420 * The filter (RFC 6750 bearer extraction + delegated token validation) is built once from the 421 * {@link #getTokenValidator() token validator} and {@link #getRealm() realm} and reused across requests rather than 422 * rebuilt per request. First use runs {@link #validateEnabled()}; under the never-mutate-after-setup contract the 423 * validator/realm are stable by the time a request reads this, so the memoized filter is authoritative. 424 * 425 * @return The bearer filter. Never <jk>null</jk>. 426 * @throws IllegalStateException If this config is enabled but misconfigured. 427 */ 428 OAuthFilter oauthFilter() { 429 var f = oauthFilter; 430 if (f == null) { 431 synchronized (this) { 432 f = oauthFilter; 433 if (f == null) { 434 validateEnabled(); 435 f = OAuthFilter.create().validator(tokenValidator).realm(realm).build(); 436 oauthFilter = f; 437 } 438 } 439 } 440 return f; 441 } 442}