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.McpAppMimeType; 020import com.soklet.internal.mcp.protocol.McpEndpointPathLimit; 021import org.jspecify.annotations.NonNull; 022import org.jspecify.annotations.Nullable; 023 024import javax.annotation.concurrent.NotThreadSafe; 025import javax.annotation.concurrent.ThreadSafe; 026import java.net.URI; 027import java.util.LinkedHashMap; 028import java.util.LinkedHashSet; 029import java.util.List; 030import java.util.Map; 031import java.util.Optional; 032import java.util.Set; 033 034import static java.util.Objects.requireNonNull; 035 036/** 037 * An immutable MCP endpoint registration. 038 * <p> 039 * An endpoint may contain only its path and server information. Such an 040 * operation-free endpoint remains valid and advertises no optional operation 041 * capabilities. 042 * 043 * @author <a href="https://www.revetkn.com">Mark Allen</a> 044 */ 045@ThreadSafe 046public final class McpEndpoint { 047 @NonNull 048 private final String path; 049 @NonNull 050 private final Set<@NonNull McpProtocolVersion> protocolVersions; 051 @NonNull 052 private final Set<@NonNull McpProtocolVersion> taskProtocolVersions; 053 @NonNull 054 private final Set<@NonNull McpProtocolVersion> sessionProtocolVersions; 055 @NonNull 056 private final Set<@NonNull McpProtocolVersion> subscriptionProtocolVersions; 057 @NonNull 058 private final McpImplementation serverInformation; 059 private final boolean serverInformationIncluded; 060 @Nullable 061 private final String instructions; 062 @NonNull 063 private final List<@NonNull McpToolRegistration<?>> toolRegistrations; 064 @NonNull 065 private final List<@NonNull McpPromptRegistration> promptRegistrations; 066 @NonNull 067 private final List<@NonNull McpResourceRegistration> resourceRegistrations; 068 @NonNull 069 private final List<@NonNull McpSkillRegistration> skillRegistrations; 070 @NonNull 071 private final List<@NonNull McpSkillGroup> skillGroups; 072 @NonNull 073 private final McpSkillEndpointIndex skillIndex; 074 @Nullable 075 private final McpSkillListHandler skillListHandler; 076 @NonNull 077 private final Set<@NonNull McpProtocolVersion> skillListHandlerProtocolVersions; 078 @NonNull 079 private final McpCachePolicy skillListCachePolicy; 080 @Nullable 081 private final McpResourceListHandler resourceListHandler; 082 @NonNull 083 private final Set<@NonNull McpProtocolVersion> resourceListHandlerProtocolVersions; 084 @NonNull 085 private final McpCachePolicy resourceListCachePolicy; 086 @NonNull 087 private final McpCachePolicy resourceTemplateListCachePolicy; 088 @Nullable 089 private final String toolRateLimiterName; 090 @Nullable 091 private final McpRateLimiter toolRateLimiter; 092 @Nullable 093 private final McpSubscriptionConfig subscriptionConfig; 094 095 /** 096 * Vends a builder primed with its required construction values. 097 * 098 * @param path the normalized absolute endpoint path in ASCII raw URI form; 099 * percent-encode non-ASCII characters. The path is not rewritten. 100 * @param implementation implementation information advertised by the endpoint 101 * @param protocolVersions nonempty exact revisions served at this URL 102 * @return a builder for endpoint registrations 103 * @throws NullPointerException if an argument is null 104 * @throws IllegalArgumentException if the path is not a non-root absolute 105 * path, is not valid ASCII raw URI form, 106 * contains a query or fragment, has a trailing 107 * slash, repeated slashes, whitespace or dot 108 * segments, or exceeds 8192 bytes, or the 109 * server name or version is blank 110 */ 111 @NonNull 112 public static Builder withPath(@NonNull String path, 113 @NonNull McpImplementation implementation, 114 @NonNull Set<@NonNull McpProtocolVersion> protocolVersions) { 115 return new Builder(normalizePath(path), implementation, 116 McpProtocolVersion.requiredSet(protocolVersions)); 117 } 118 119 private McpEndpoint(@NonNull Builder builder) { 120 requireNonNull(builder); 121 122 this.path = builder.path; 123 this.protocolVersions = builder.protocolVersions; 124 this.taskProtocolVersions = builder.taskProtocolVersions; 125 this.sessionProtocolVersions = builder.sessionProtocolVersions; 126 this.subscriptionProtocolVersions = builder.subscriptionProtocolVersions; 127 this.serverInformation = builder.serverInformation; 128 this.serverInformationIncluded = builder.serverInformationIncluded; 129 this.instructions = builder.instructions; 130 this.toolRegistrations = List.copyOf(builder.toolRegistrations); 131 this.promptRegistrations = List.copyOf(builder.promptRegistrations); 132 this.resourceRegistrations = List.copyOf(builder.resourceRegistrations); 133 this.skillRegistrations = List.copyOf(builder.skillRegistrations); 134 this.skillGroups = List.copyOf(builder.skillGroups); 135 this.skillIndex = McpSkillEndpointIndex.from(this.skillRegistrations, this.skillGroups, 136 this.resourceRegistrations); 137 this.skillListHandler = builder.skillListHandler; 138 this.skillListHandlerProtocolVersions = builder.skillListHandlerProtocolVersions; 139 this.skillListCachePolicy = builder.skillListCachePolicy; 140 this.resourceListHandler = builder.resourceListHandler; 141 this.resourceListHandlerProtocolVersions = builder.resourceListHandlerProtocolVersions; 142 this.resourceListCachePolicy = builder.resourceListCachePolicy; 143 this.resourceTemplateListCachePolicy = 144 builder.resourceTemplateListCachePolicy; 145 this.toolRateLimiterName = builder.toolRateLimiterName; 146 this.toolRateLimiter = builder.toolRateLimiter; 147 this.subscriptionConfig = builder.subscriptionConfig; 148 if (this.protocolVersions.contains(McpProtocolVersion.V2025_03_26)) 149 throw new IllegalStateException( 150 "MCP 2025-03-26 is not implemented by this adapter" + versionContext(Set.of(McpProtocolVersion.V2025_03_26))); 151 152 requireSubset(this.taskProtocolVersions, this.protocolVersions, "Tasks"); 153 requireSubset(this.sessionProtocolVersions, this.protocolVersions, "sessions"); 154 if (!Set.of(McpProtocolVersion.V2025_06_18, 155 McpProtocolVersion.V2025_11_25).containsAll(this.sessionProtocolVersions)) 156 throw new IllegalStateException( 157 "Sessions currently require MCP 2025-06-18 or 2025-11-25" + versionContext(this.sessionProtocolVersions)); 158 requireSubset(this.subscriptionProtocolVersions, this.protocolVersions, 159 "subscriptions"); 160 requireSubset(this.skillListHandlerProtocolVersions, this.protocolVersions, 161 "Skills-list handler"); 162 requireSubset(this.resourceListHandlerProtocolVersions, this.protocolVersions, 163 "resource-list handler"); 164 if (!Set.of(McpProtocolVersion.V2026_07_28).containsAll( 165 this.taskProtocolVersions)) 166 throw new IllegalStateException( 167 "Tasks currently require MCP 2026-07-28" + versionContext(this.taskProtocolVersions)); 168 if (!Set.of(McpProtocolVersion.V2025_06_18, McpProtocolVersion.V2025_11_25, 169 McpProtocolVersion.V2026_07_28).containsAll( 170 this.subscriptionProtocolVersions)) 171 throw new IllegalStateException( 172 "Subscriptions currently require MCP 2025-06-18, 2025-11-25, or 2026-07-28" + versionContext(this.subscriptionProtocolVersions)); 173 for (McpProtocolVersion subscriptionProtocolVersion : this.subscriptionProtocolVersions) 174 if (subscriptionProtocolVersion != McpProtocolVersion.V2026_07_28 175 && !this.sessionProtocolVersions.contains(subscriptionProtocolVersion)) 176 throw new IllegalStateException( 177 "Legacy subscription revisions must also enable sessions" + versionContext(Set.of(subscriptionProtocolVersion))); 178 if (this.subscriptionConfig != null && this.subscriptionProtocolVersions.isEmpty()) 179 throw new IllegalStateException( 180 "MCP subscription configuration requires an enabled protocol revision" + versionContext(this.protocolVersions)); 181 182 Set<String> toolNames = new LinkedHashSet<>(); 183 for (McpToolRegistration<?> tool : this.toolRegistrations) { 184 requireSubset(tool.getProtocolVersions(), this.protocolVersions, 185 "tool " + tool.getName()); 186 if (containsLegacyVersion(tool.getProtocolVersions()) 187 && tool.getOutputSchema().isPresent() 188 && !McpJsonString.fromValue("object").equals(tool.getOutputSchema().orElseThrow() 189 .getDocument().getMembers().get("type"))) 190 throw new IllegalStateException("MCP tool '" + tool.getName() 191 + "' output schema must have object type for the 2025 adapter" 192 + versionContext(tool.getProtocolVersions())); 193 if (containsLegacyVersion(tool.getProtocolVersions()) 194 && (!tool.getInputRequestDeclarations().isEmpty() 195 || tool.getRequestStateMode() != McpRequestStateMode.NONE 196 || !tool.getMirroredHeaderPlan().declarations().isEmpty())) 197 throw new IllegalStateException( 198 "The 2025 tools adapter cannot serve input requests, request state, or mirrored headers for tool " 199 + tool.getName() + versionContext(tool.getProtocolVersions())); 200 tool.getAppToolMetadata().ifPresent(metadata -> { 201 if (containsLegacyVersion(metadata.getProtocolVersions())) 202 throw new IllegalStateException( 203 "MCP Apps metadata for tool '" + tool.getName() + "' is not implemented by the 2025 adapter" 204 + versionContext(metadata.getProtocolVersions())); 205 if (!metadata.getVisibility().contains(McpAppToolMetadata.Visibility.MODEL) 206 && containsLegacyVersion(tool.getProtocolVersions())) 207 throw new IllegalStateException( 208 "App-only tools cannot be exposed by the 2025 tools-only adapter: " + tool.getName() 209 + versionContext(tool.getProtocolVersions())); 210 }); 211 if (tool.isTaskRequired()) 212 requireSubset(tool.getProtocolVersions(), this.taskProtocolVersions, 213 "task-required tool " + tool.getName()); 214 if (!toolNames.add(tool.getName())) 215 throw new IllegalStateException( 216 "Duplicate MCP tool name: " + tool.getName()); 217 } 218 Set<String> promptNames = new LinkedHashSet<>(); 219 for (McpPromptRegistration prompt : this.promptRegistrations) { 220 requireSubset(prompt.getProtocolVersions(), this.protocolVersions, 221 "prompt " + prompt.getName()); 222 if (containsLegacyVersion(prompt.getProtocolVersions()) 223 && (!prompt.getInputRequestDeclarations().isEmpty() 224 || prompt.getRequestStateMode() != McpRequestStateMode.NONE)) 225 throw new IllegalStateException( 226 "The 2025 prompt adapter cannot serve input requests or request state for prompt " 227 + prompt.getName() + versionContext(prompt.getProtocolVersions())); 228 if (!promptNames.add(prompt.getName())) 229 throw new IllegalStateException( 230 "Duplicate MCP prompt name: " + prompt.getName()); 231 } 232 Map<URI, McpResourceRegistration> exactResources = new LinkedHashMap<>(); 233 Set<String> resourceUriTemplates = new LinkedHashSet<>(); 234 for (McpResourceRegistration resource : this.resourceRegistrations) { 235 requireSubset(resource.getProtocolVersions(), this.protocolVersions, 236 "resource " + resource.getName()); 237 if (containsLegacyVersion(resource.getProtocolVersions())) { 238 if (!resource.getInputRequestDeclarations().isEmpty() 239 || resource.getRequestStateMode() != McpRequestStateMode.NONE) 240 throw new IllegalStateException( 241 "The 2025 resource adapter cannot serve input requests or request state for resource " 242 + resource.getName() + versionContext(resource.getProtocolVersions())); 243 if (hasAppsMimeType(resource) 244 || resource.getMetadata().getMembers().containsKey("ui")) 245 throw new IllegalStateException( 246 "MCP Apps resources are not implemented by the 2025 adapter: " + resource.getName() 247 + versionContext(resource.getProtocolVersions())); 248 } 249 if (resource.getAddressType() == McpResourceAddressType.URI) { 250 URI uri = resource.getUri().orElseThrow(); 251 if (exactResources.putIfAbsent(uri, resource) != null) 252 throw new IllegalStateException( 253 "Duplicate MCP exact resource URI: " + uri); 254 } else { 255 String uriTemplate = resource.getUriTemplate().orElseThrow(); 256 if (!resourceUriTemplates.add(uriTemplate)) 257 throw new IllegalStateException( 258 "Duplicate MCP resource URI template: " + uriTemplate); 259 } 260 } 261 for (McpSkillRegistration skill : this.skillRegistrations) 262 requireSkillVersions(skill); 263 for (McpSkillGroup group : this.skillGroups) 264 for (McpSkillRegistration skill : group.getSkillRegistrations()) 265 requireSkillVersions(skill); 266 requireModernOnly(this.skillListHandlerProtocolVersions, 267 "Skills-list handlers"); 268 for (McpToolRegistration<?> tool : this.toolRegistrations) { 269 Optional<URI> resourceUri = McpAppMetadataSupport 270 .effectiveToolMetadata(tool.getMetadata(), 271 tool.getAppToolMetadata().orElse(null)) 272 .flatMap(McpAppToolMetadata::getResourceUri); 273 if (resourceUri.isPresent()) { 274 McpResourceRegistration resource = exactResources.get(resourceUri.get()); 275 if (resource == null || !hasAppsMimeType(resource)) 276 throw new IllegalStateException( 277 "MCP Apps tool associations require an exact UI resource " 278 + "registration with the Apps MIME profile on the same endpoint for tool " 279 + tool.getName() + versionContext(tool.getProtocolVersions())); 280 tool.getAppToolMetadata().ifPresent(metadata -> requireSubset( 281 metadata.getProtocolVersions(), resource.getProtocolVersions(), 282 "Apps UI resource for tool " + tool.getName())); 283 } 284 } 285 McpSkillEndpointIndex.preflight(this); 286 } 287 288 private void requireSkillVersions(@NonNull McpSkillRegistration skill) { 289 requireSubset(skill.getProtocolVersions(), this.protocolVersions, 290 "Skill " + skill.getSkillBundle().getName()); 291 if (!Set.of(McpProtocolVersion.V2026_07_28).containsAll( 292 skill.getProtocolVersions())) 293 throw new IllegalStateException( 294 "Skills currently require MCP 2026-07-28: " + skill.getSkillBundle().getName() 295 + versionContext(skill.getProtocolVersions())); 296 } 297 298 private void requireModernOnly( 299 @NonNull Set<@NonNull McpProtocolVersion> protocolVersions, 300 @NonNull String feature) { 301 if (containsLegacyVersion(protocolVersions)) 302 throw new IllegalStateException("The 2025 adapter does not implement MCP " 303 + feature + versionContext(protocolVersions)); 304 } 305 306 private static boolean containsLegacyVersion( 307 @NonNull Set<@NonNull McpProtocolVersion> protocolVersions) { 308 return protocolVersions.stream().anyMatch(version -> 309 version != McpProtocolVersion.V2026_07_28); 310 } 311 312 private void requireSubset( 313 @NonNull Set<@NonNull McpProtocolVersion> selected, 314 @NonNull Set<@NonNull McpProtocolVersion> owner, 315 @NonNull String feature) { 316 if (!owner.containsAll(selected)) 317 throw new IllegalStateException("MCP " + feature 318 + " revisions must be a subset of their owner revisions" 319 + versionContext(selected.stream().filter(version -> !owner.contains(version)) 320 .collect(java.util.stream.Collectors.toSet()))); 321 } 322 323 private @NonNull String versionContext(@NonNull Set<@NonNull McpProtocolVersion> versions) { 324 return " (endpoint " + this.path + "; revisions " + versions.stream() 325 .map(McpProtocolVersion::getWireValue).sorted().collect(java.util.stream.Collectors.joining(", ")) + ")."; 326 } 327 328 private static boolean hasAppsMimeType(@NonNull McpResourceRegistration resource) { 329 String mimeType = resource.getMimeType().orElse(null); 330 if (mimeType == null) 331 return false; 332 try { 333 return McpAppMimeType.isAppsProfile(mimeType); 334 } catch (IllegalArgumentException exception) { 335 return false; 336 } 337 } 338 339 private McpEndpoint(@NonNull McpEndpoint endpoint, 340 @NonNull List<@NonNull McpSkillRegistration> skillRegistrations, 341 @NonNull List<@NonNull McpSkillGroup> skillGroups, 342 @NonNull McpSkillEndpointIndex skillIndex, 343 @Nullable McpSubscriptionConfig subscriptionConfig) { 344 requireNonNull(endpoint); 345 this.path = endpoint.path; 346 this.protocolVersions = endpoint.protocolVersions; 347 this.taskProtocolVersions = endpoint.taskProtocolVersions; 348 this.sessionProtocolVersions = endpoint.sessionProtocolVersions; 349 this.subscriptionProtocolVersions = endpoint.subscriptionProtocolVersions; 350 this.serverInformation = endpoint.serverInformation; 351 this.serverInformationIncluded = endpoint.serverInformationIncluded; 352 this.instructions = endpoint.instructions; 353 this.toolRegistrations = endpoint.toolRegistrations; 354 this.promptRegistrations = endpoint.promptRegistrations; 355 this.resourceRegistrations = endpoint.resourceRegistrations; 356 this.skillRegistrations = skillRegistrations; 357 this.skillGroups = skillGroups; 358 this.skillIndex = skillIndex; 359 this.skillListHandler = endpoint.skillListHandler; 360 this.skillListHandlerProtocolVersions = endpoint.skillListHandlerProtocolVersions; 361 this.skillListCachePolicy = endpoint.skillListCachePolicy; 362 this.resourceListHandler = endpoint.resourceListHandler; 363 this.resourceListHandlerProtocolVersions = endpoint.resourceListHandlerProtocolVersions; 364 this.resourceListCachePolicy = endpoint.resourceListCachePolicy; 365 this.resourceTemplateListCachePolicy = 366 endpoint.resourceTemplateListCachePolicy; 367 this.toolRateLimiterName = endpoint.toolRateLimiterName; 368 this.toolRateLimiter = endpoint.toolRateLimiter; 369 this.subscriptionConfig = subscriptionConfig; 370 } 371 372 /** 373 * The normalized absolute endpoint path. 374 * 375 * @return the endpoint path 376 */ 377 @NonNull 378 public String getPath() { 379 return this.path; 380 } 381 382 /** @return exact protocol revisions served at this endpoint */ 383 @NonNull 384 public Set<@NonNull McpProtocolVersion> getProtocolVersions() { 385 return this.protocolVersions; 386 } 387 388 /** @return exact revisions that enable Tasks on this endpoint */ 389 @NonNull 390 public Set<@NonNull McpProtocolVersion> getTaskProtocolVersions() { 391 return this.taskProtocolVersions; 392 } 393 394 /** 395 * Returns the exact session-enabled subset of this endpoint's revisions. 396 * Only MCP {@code 2025-06-18} and {@code 2025-11-25} are eligible; 397 * Soklet's {@code 2026-07-28} implementation does not use sessions. 398 * 399 * @return immutable session revisions, or an empty set when disabled 400 */ 401 @NonNull 402 public Set<@NonNull McpProtocolVersion> getSessionProtocolVersions() { 403 return this.sessionProtocolVersions; 404 } 405 406 /** 407 * Returns exact subscription-enabled revisions. Modern {@code 2026-07-28} 408 * uses {@code subscriptions/listen}; {@code 2025-06-18} and 409 * {@code 2025-11-25} use session GET delivery and must also enable sessions. 410 * Legacy delivery additionally requires the server's HTTP admission 411 * controller and an effective event-source family set. 412 * 413 * @return immutable exact subscription-enabled revisions 414 */ 415 @NonNull 416 public Set<@NonNull McpProtocolVersion> getSubscriptionProtocolVersions() { 417 return this.subscriptionProtocolVersions; 418 } 419 420 /** 421 * The required implementation information advertised by this endpoint. 422 * 423 * @return the server implementation information 424 */ 425 @NonNull 426 public McpImplementation getServerInfo() { 427 return this.serverInformation; 428 } 429 430 /** 431 * Indicates whether Soklet includes the configured server implementation at 432 * {@code _meta["io.modelcontextprotocol/serverInfo"]} in MCP results. 433 * 434 * @return {@code true} when MCP result metadata includes server information 435 */ 436 @NonNull 437 public Boolean isServerInfoIncluded() { 438 return this.serverInformationIncluded; 439 } 440 441 /** 442 * Optional human-readable instructions for clients using this endpoint. 443 * 444 * @return the instructions, or the empty optional if none were configured 445 */ 446 @NonNull 447 public Optional<@NonNull String> getInstructions() { 448 return Optional.ofNullable(this.instructions); 449 } 450 451 /** 452 * Returns the tools exposed by this endpoint in registration order. 453 * 454 * @return immutable tool registrations 455 */ 456 @NonNull 457 public List<@NonNull McpToolRegistration<?>> getToolRegistrations() { 458 return this.toolRegistrations; 459 } 460 461 /** 462 * Returns the prompts exposed by this endpoint in registration order. 463 * 464 * @return immutable prompt registrations 465 */ 466 @NonNull 467 public List<@NonNull McpPromptRegistration> getPromptRegistrations() { 468 return this.promptRegistrations; 469 } 470 471 /** 472 * Returns exact-URI and URI-template resource registrations in registration 473 * order. 474 * <p> 475 * When {@link #getResourceListHandler()} is empty, Soklet derives the static 476 * {@code resources/list} page from only the exact-URI registrations in this 477 * list. Template registrations are advertised separately by 478 * {@code resources/templates/list}. 479 * 480 * @return immutable resource registrations 481 */ 482 @NonNull 483 public List<@NonNull McpResourceRegistration> getResourceRegistrations() { 484 return this.resourceRegistrations; 485 } 486 487 /** 488 * Returns only standalone Skills registrations in supplied order. Group members 489 * remain in {@link #getSkillGroups()}; this getter does not flatten them. 490 * Skills manifests and files are served only on their declared protocol revisions. 491 * 492 * @return immutable standalone Skills registrations 493 */ 494 @NonNull 495 public List<@NonNull McpSkillRegistration> getSkillRegistrations() { 496 return this.skillRegistrations; 497 } 498 499 /** 500 * Returns explicit Skills locale groups in supplied order, without selecting 501 * a locale or granting access to any member. 502 * 503 * @return immutable Skills groups 504 */ 505 @NonNull 506 public List<@NonNull McpSkillGroup> getSkillGroups() { 507 return this.skillGroups; 508 } 509 510 @NonNull 511 McpSkillEndpointIndex skillIndex() { return this.skillIndex; } 512 513 /** 514 * Returns the optional application-owned {@code skills/list} page handler. 515 * When absent, Soklet produces one bounded automatic page. A custom handler 516 * owns pagination and retained snapshots, not canonical skill-file reads. 517 * 518 * @return custom Skills-list handler, or empty for automatic listing 519 */ 520 @NonNull 521 public Optional<@NonNull McpSkillListHandler> getSkillListHandler() { 522 return Optional.ofNullable(this.skillListHandler); 523 } 524 525 /** @return revisions on which the custom Skills-list handler applies */ 526 @NonNull 527 public Set<@NonNull McpProtocolVersion> getSkillListHandlerProtocolVersions() { 528 return this.skillListHandlerProtocolVersions; 529 } 530 531 /** @return fixed Skills-list cache scope and default time to live */ 532 @NonNull 533 public McpCachePolicy getSkillListCachePolicy() { return this.skillListCachePolicy; } 534 535 /** 536 * Returns the optional sole custom {@code resources/list} handler. 537 * <p> 538 * When enabled for the selected revision, the returned handler is 539 * authoritative; Soklet does not merge exact registrations into its pages. 540 * Otherwise the endpoint uses the static fallback: a single page on 541 * {@code 2026-07-28}, or bounded framework pages on {@code 2025-06-18} and 542 * {@code 2025-11-25}. 543 * 544 * @return custom resource-list handler, or empty for the static fallback 545 */ 546 @NonNull 547 public Optional<@NonNull McpResourceListHandler> getResourceListHandler() { 548 return Optional.ofNullable(this.resourceListHandler); 549 } 550 551 /** @return revisions on which the custom resource-list handler applies */ 552 @NonNull 553 public Set<@NonNull McpProtocolVersion> getResourceListHandlerProtocolVersions() { 554 return this.resourceListHandlerProtocolVersions; 555 } 556 557 /** 558 * Returns the fixed cache policy for every {@code resources/list} page. 559 * 560 * @return resources-list cache policy 561 */ 562 @NonNull 563 public McpCachePolicy getResourceListCachePolicy() { 564 return this.resourceListCachePolicy; 565 } 566 567 /** 568 * Returns the fixed cache policy for {@code resources/templates/list}. 569 * 570 * @return resource-template-list cache policy 571 */ 572 @NonNull 573 public McpCachePolicy getResourceTemplateListCachePolicy() { 574 return this.resourceTemplateListCachePolicy; 575 } 576 577 /** 578 * Returns the named tool-limiter override. 579 * <p> 580 * At most one of this value and {@link #getToolRateLimiter()} is present. 581 * 582 * @return registry limiter name, or the empty optional for a direct or 583 * inherited limiter 584 */ 585 @NonNull 586 public Optional<@NonNull String> getToolRateLimiterName() { 587 return Optional.ofNullable(this.toolRateLimiterName); 588 } 589 590 /** 591 * Returns the direct tool-limiter override. 592 * <p> 593 * At most one of this value and {@link #getToolRateLimiterName()} is present. 594 * 595 * @return direct limiter, or the empty optional for a named or inherited 596 * limiter 597 */ 598 @NonNull 599 public Optional<@NonNull McpRateLimiter> getToolRateLimiter() { 600 return Optional.ofNullable(this.toolRateLimiter); 601 } 602 603 /** 604 * Returns this endpoint's subscription-change configuration. 605 * 606 * @return subscription configuration, or the empty optional if none was 607 * configured 608 */ 609 @NonNull 610 public Optional<@NonNull McpSubscriptionConfig> getSubscriptionConfig() { 611 return Optional.ofNullable(this.subscriptionConfig); 612 } 613 614 @NonNull 615 McpEndpoint withSubscriptionConfig( 616 @NonNull McpSubscriptionConfig subscriptionConfig) { 617 if (this.subscriptionProtocolVersions.isEmpty()) 618 throw new IllegalStateException( 619 "MCP subscription configuration requires an enabled protocol revision."); 620 return new McpEndpoint(this, this.skillRegistrations, this.skillGroups, this.skillIndex, 621 requireNonNull(subscriptionConfig)); 622 } 623 624 @NonNull 625 McpEndpoint withSkillRegistrations( 626 @NonNull List<@NonNull McpSkillRegistration> skillRegistrations) { 627 List<McpSkillRegistration> snapshot = List.copyOf(skillRegistrations); 628 McpSkillEndpointIndex skillIndex = McpSkillEndpointIndex.from(snapshot, 629 this.skillGroups, this.resourceRegistrations); 630 McpEndpoint endpoint = new McpEndpoint(this, snapshot, this.skillGroups, skillIndex, 631 this.subscriptionConfig); 632 for (McpSkillRegistration skill : snapshot) 633 endpoint.requireSkillVersions(skill); 634 McpSkillEndpointIndex.preflight(endpoint); 635 return endpoint; 636 } 637 638 @NonNull 639 McpEndpoint withSkillGroups( 640 @NonNull List<@NonNull McpSkillGroup> skillGroups) { 641 List<McpSkillGroup> snapshot = List.copyOf(skillGroups); 642 McpSkillEndpointIndex skillIndex = McpSkillEndpointIndex.from(this.skillRegistrations, 643 snapshot, this.resourceRegistrations); 644 McpEndpoint endpoint = new McpEndpoint(this, this.skillRegistrations, snapshot, skillIndex, 645 this.subscriptionConfig); 646 for (McpSkillGroup group : snapshot) 647 for (McpSkillRegistration skill : group.getSkillRegistrations()) 648 endpoint.requireSkillVersions(skill); 649 McpSkillEndpointIndex.preflight(endpoint); 650 return endpoint; 651 } 652 653 @NonNull 654 static String normalizePath(@NonNull String path) { 655 return McpEndpointPathLimit.requireValidWirePath(path); 656 } 657 658 /** 659 * Builder for immutable {@link McpEndpoint} registrations. 660 * <p> 661 * This class is intended for use by a single thread. 662 * 663 * @author <a href="https://www.revetkn.com">Mark Allen</a> 664 */ 665 @NotThreadSafe 666 public static final class Builder { 667 @NonNull 668 private final String path; 669 @NonNull 670 private final Set<@NonNull McpProtocolVersion> protocolVersions; 671 @NonNull 672 private Set<@NonNull McpProtocolVersion> taskProtocolVersions = Set.of(); 673 @NonNull 674 private Set<@NonNull McpProtocolVersion> sessionProtocolVersions = Set.of(); 675 @NonNull 676 private Set<@NonNull McpProtocolVersion> subscriptionProtocolVersions = Set.of(); 677 @NonNull 678 private McpImplementation serverInformation; 679 private boolean serverInformationIncluded; 680 @Nullable 681 private String instructions; 682 @NonNull 683 private List<@NonNull McpToolRegistration<?>> toolRegistrations; 684 @NonNull 685 private List<@NonNull McpPromptRegistration> promptRegistrations; 686 @NonNull 687 private List<@NonNull McpResourceRegistration> resourceRegistrations; 688 @NonNull 689 private List<@NonNull McpSkillRegistration> skillRegistrations; 690 @NonNull 691 private List<@NonNull McpSkillGroup> skillGroups; 692 @Nullable 693 private McpSkillListHandler skillListHandler; 694 @NonNull 695 private Set<@NonNull McpProtocolVersion> skillListHandlerProtocolVersions = Set.of(); 696 @NonNull 697 private McpCachePolicy skillListCachePolicy; 698 @Nullable 699 private McpResourceListHandler resourceListHandler; 700 @NonNull 701 private Set<@NonNull McpProtocolVersion> resourceListHandlerProtocolVersions = Set.of(); 702 @NonNull 703 private McpCachePolicy resourceListCachePolicy; 704 @NonNull 705 private McpCachePolicy resourceTemplateListCachePolicy; 706 @Nullable 707 private String toolRateLimiterName; 708 @Nullable 709 private McpRateLimiter toolRateLimiter; 710 @Nullable 711 private McpSubscriptionConfig subscriptionConfig; 712 713 private Builder(@NonNull String path, 714 @NonNull McpImplementation implementation, 715 @NonNull Set<@NonNull McpProtocolVersion> protocolVersions) { 716 this.path = requireNonNull(path); 717 this.protocolVersions = requireNonNull(protocolVersions); 718 this.serverInformation = requireServerInformation(implementation); 719 this.serverInformationIncluded = true; 720 this.toolRegistrations = List.of(); 721 this.promptRegistrations = List.of(); 722 this.resourceRegistrations = List.of(); 723 this.skillRegistrations = List.of(); 724 this.skillGroups = List.of(); 725 this.skillListCachePolicy = McpCachePolicy.privateNoCacheInstance(); 726 this.resourceListCachePolicy = 727 McpCachePolicy.privateNoCacheInstance(); 728 this.resourceTemplateListCachePolicy = 729 McpCachePolicy.privateNoCacheInstance(); 730 } 731 732 /** 733 * Enables Tasks for an explicit subset of endpoint revisions. An empty set 734 * disables Tasks. The task manager remains a server dependency. 735 * 736 * @param protocolVersions exact task-enabled revisions 737 * @return this builder 738 */ 739 @NonNull 740 public Builder taskProtocolVersions( 741 @NonNull Set<@NonNull McpProtocolVersion> protocolVersions) { 742 this.taskProtocolVersions = McpProtocolVersion.optionalSet(protocolVersions); 743 return this; 744 } 745 746 /** 747 * Enables sessions for an explicit subset of served endpoint revisions. 748 * Only {@code 2025-06-18} and {@code 2025-11-25} are eligible; an empty 749 * set disables sessions. {@code 2026-07-28} does not use sessions. 750 * The server must configure {@link McpSessionConfig}. A session-enabled 751 * revision requires a session ID after initialization; it never silently 752 * falls back to stateless operation. The supplied set is copied. 753 * 754 * @param protocolVersions exact session-enabled revisions 755 * @return this builder 756 * @throws NullPointerException if the set or an item is null 757 */ 758 @NonNull 759 public Builder sessionProtocolVersions( 760 @NonNull Set<@NonNull McpProtocolVersion> protocolVersions) { 761 this.sessionProtocolVersions = McpProtocolVersion.optionalSet(protocolVersions); 762 return this; 763 } 764 765 /** 766 * Enables subscriptions for an explicit subset of endpoint revisions. 767 * Modern {@code 2026-07-28} uses {@code subscriptions/listen}; the two 768 * supported 2025 revisions require matching session revisions, server HTTP 769 * admission and effective event sources for GET delivery. An empty set 770 * disables subscriptions. URI updates require an explicit URI authorizer; 771 * legacy catalog-only delivery does not. 772 * 773 * @param protocolVersions exact subscription-enabled revisions 774 * @return this builder 775 */ 776 @NonNull 777 public Builder subscriptionProtocolVersions( 778 @NonNull Set<@NonNull McpProtocolVersion> protocolVersions) { 779 this.subscriptionProtocolVersions = McpProtocolVersion.optionalSet(protocolVersions); 780 return this; 781 } 782 783 /** 784 * Sets the required implementation information advertised by this endpoint. 785 * 786 * @param implementation the server implementation information 787 * @return this builder 788 * @throws IllegalArgumentException if the server name or version is blank 789 */ 790 @NonNull 791 public Builder serverInfo( 792 @NonNull McpImplementation implementation) { 793 this.serverInformation = requireServerInformation(implementation); 794 return this; 795 } 796 797 @NonNull 798 private static McpImplementation requireServerInformation( 799 @NonNull McpImplementation implementation) { 800 requireNonNull(implementation); 801 if (implementation.getName().isBlank() || implementation.getVersion().isBlank()) 802 throw new IllegalArgumentException( 803 "MCP server implementation name and version must not be blank."); 804 return implementation; 805 } 806 807 /** 808 * Controls whether Soklet includes the configured server implementation at 809 * {@code _meta["io.modelcontextprotocol/serverInfo"]} in MCP results. The 810 * default is {@code true}. 811 * 812 * @param serverInfoIncluded whether MCP result metadata includes server 813 * information, or null to restore the default 814 * @return this builder 815 */ 816 @NonNull 817 public Builder serverInfoIncluded( 818 @Nullable Boolean serverInfoIncluded) { 819 this.serverInformationIncluded = serverInfoIncluded == null 820 ? true : serverInfoIncluded; 821 return this; 822 } 823 824 /** 825 * Sets nonblank human-readable instructions for clients using this endpoint. 826 * 827 * @param instructions the endpoint instructions, or null to clear them 828 * @return this builder 829 * @throws IllegalArgumentException if the instructions are blank 830 */ 831 @NonNull 832 public Builder instructions(@Nullable String instructions) { 833 if (instructions != null) McpContentValueSupport.requireWellFormedString(instructions); 834 if (instructions != null && instructions.isBlank()) 835 throw new IllegalArgumentException( 836 "MCP endpoint instructions must not be blank."); 837 838 this.instructions = instructions; 839 return this; 840 } 841 842 /** 843 * Replaces tool registrations in supplied order. 844 * Null or empty clears the property. The complete list is validated and 845 * snapshotted before replacing the prior value. 846 * 847 * @param toolRegistrations tool registrations, or null to clear 848 * @return this builder 849 * @throws NullPointerException if a list element is null 850 */ 851 @NonNull 852 public Builder toolRegistrations( 853 @Nullable List<@NonNull McpToolRegistration<?>> toolRegistrations) { 854 this.toolRegistrations = toolRegistrations == null ? List.of() 855 : List.copyOf(toolRegistrations); 856 return this; 857 } 858 859 /** 860 * Replaces prompt registrations in supplied order. 861 * Null or empty clears the property. The complete list is validated and 862 * snapshotted before replacing the prior value. 863 * 864 * @param promptRegistrations prompt registrations, or null to clear 865 * @return this builder 866 * @throws NullPointerException if a list element is null 867 */ 868 @NonNull 869 public Builder promptRegistrations( 870 @Nullable List<@NonNull McpPromptRegistration> promptRegistrations) { 871 this.promptRegistrations = promptRegistrations == null ? List.of() 872 : List.copyOf(promptRegistrations); 873 return this; 874 } 875 876 /** 877 * Replaces resource registrations in supplied order. 878 * Null or empty clears the property. The complete list is validated and 879 * snapshotted before replacing the prior value. 880 * 881 * @param resourceRegistrations resource registrations, or null to clear 882 * @return this builder 883 * @throws NullPointerException if a list element is null 884 */ 885 @NonNull 886 public Builder resourceRegistrations( 887 @Nullable List<@NonNull McpResourceRegistration> resourceRegistrations) { 888 this.resourceRegistrations = resourceRegistrations == null ? List.of() 889 : List.copyOf(resourceRegistrations); 890 return this; 891 } 892 893 /** 894 * Replaces standalone Skills registrations in supplied order. Null or empty 895 * clears this property without changing groups. The complete list is 896 * snapshotted before replacing the prior value. Endpoint construction checks 897 * duplicate identities, listing names, shared files and resource collisions 898 * across both sources, independent of setter order. 899 * 900 * @param skillRegistrations standalone Skills registrations, or null to clear 901 * @return this builder 902 * @throws NullPointerException if a list element is null 903 */ 904 @NonNull 905 public Builder skillRegistrations( 906 @Nullable List<@NonNull McpSkillRegistration> skillRegistrations) { 907 this.skillRegistrations = skillRegistrations == null ? List.of() : List.copyOf(skillRegistrations); 908 return this; 909 } 910 911 /** 912 * Replaces explicit Skills locale groups in supplied order. Null or empty 913 * clears this property without changing standalone registrations. The 914 * complete list is snapshotted before replacing the prior value. Empty 915 * groups are permitted but contribute no listing name or file owner. 916 * 917 * @param skillGroups Skills groups, or null to clear 918 * @return this builder 919 * @throws NullPointerException if a list element is null 920 */ 921 @NonNull 922 public Builder skillGroups(@Nullable List<@NonNull McpSkillGroup> skillGroups) { 923 this.skillGroups = skillGroups == null ? List.of() : List.copyOf(skillGroups); 924 return this; 925 } 926 927 /** 928 * Installs the application-owned {@code skills/list} page handler. 929 * Each invocation replaces the prior handler. Null restores bounded 930 * automatic single-page listing. The handler owns authenticated cursors and 931 * snapshot restoration, while Soklet validates page membership and current 932 * access without changing canonical file-read handling. 933 * 934 * @param skillListHandler custom Skills-list handler, or null for automatic listing 935 * @param protocolVersions nonempty handler revisions, or empty when clearing 936 * @return this builder 937 */ 938 @NonNull 939 public Builder skillListHandler(@Nullable McpSkillListHandler skillListHandler, 940 @NonNull Set<@NonNull McpProtocolVersion> protocolVersions) { 941 Set<McpProtocolVersion> versions = skillListHandler == null 942 ? McpProtocolVersion.optionalSet(protocolVersions) 943 : McpProtocolVersion.requiredSet(protocolVersions); 944 if (skillListHandler == null && !versions.isEmpty()) 945 throw new IllegalArgumentException( 946 "A null MCP Skills-list handler requires an empty version set."); 947 this.skillListHandler = skillListHandler; 948 this.skillListHandlerProtocolVersions = versions; 949 return this; 950 } 951 952 /** 953 * Sets fixed cache scope and default freshness for {@code skills/list}. 954 * The default is private scope with zero time to live. Page overrides affect 955 * only freshness and remain subject to localization and security clamps. 956 * 957 * @param skillListCachePolicy Skills-list cache policy, or null to restore the default 958 * @return this builder 959 */ 960 @NonNull 961 public Builder skillListCachePolicy(@Nullable McpCachePolicy skillListCachePolicy) { 962 this.skillListCachePolicy = skillListCachePolicy == null 963 ? McpCachePolicy.privateNoCacheInstance() : skillListCachePolicy; 964 return this; 965 } 966 967 /** 968 * Installs the custom {@code resources/list} handler. 969 * <p> 970 * A custom handler is authoritative for every returned page; exact resource 971 * registrations are not merged automatically. Every descriptor with an 972 * exact {@code uri} must identify either an exact-URI resource registration 973 * or a matching URI-template registration on this endpoint. Soklet validates 974 * the complete page after the handler returns; a violation rejects the request 975 * with JSON-RPC error {@code -32603} and HTTP 976 * status {@code 200} on {@code 2026-07-28} (the 2025 profiles also retain 977 * in-band operation errors). Null selects the static fallback: a single page on 978 * {@code 2026-07-28}, or bounded framework pages on {@code 2025-06-18} and 979 * {@code 2025-11-25}. 980 * Sequential calls are last-call-wins. 981 * 982 * @param resourceListHandler custom list handler, or null for the static 983 * fallback 984 * @param protocolVersions nonempty handler revisions, or empty when clearing 985 * @return this builder 986 */ 987 @NonNull 988 public Builder resourceListHandler( 989 @Nullable McpResourceListHandler resourceListHandler, 990 @NonNull Set<@NonNull McpProtocolVersion> protocolVersions) { 991 Set<McpProtocolVersion> versions = resourceListHandler == null 992 ? McpProtocolVersion.optionalSet(protocolVersions) 993 : McpProtocolVersion.requiredSet(protocolVersions); 994 if (resourceListHandler == null && !versions.isEmpty()) 995 throw new IllegalArgumentException( 996 "A null MCP resource-list handler requires an empty version set."); 997 this.resourceListHandler = resourceListHandler; 998 this.resourceListHandlerProtocolVersions = versions; 999 return this; 1000 } 1001 1002 /** 1003 * Sets the fixed scope and default time to live for every 1004 * {@code resources/list} page. The default is private scope with a zero 1005 * time to live. 1006 * 1007 * @param resourceListCachePolicy resources-list cache policy, or null to 1008 * restore the default 1009 * @return this builder 1010 */ 1011 @NonNull 1012 public Builder resourceListCachePolicy( 1013 @Nullable McpCachePolicy resourceListCachePolicy) { 1014 this.resourceListCachePolicy = resourceListCachePolicy == null 1015 ? McpCachePolicy.privateNoCacheInstance() 1016 : resourceListCachePolicy; 1017 return this; 1018 } 1019 1020 /** 1021 * Sets the fixed scope and default time to live for 1022 * {@code resources/templates/list}. The default is private scope with a 1023 * zero time to live. 1024 * 1025 * @param resourceTemplateListCachePolicy resource-template-list cache 1026 * policy, or null to restore the 1027 * default 1028 * @return this builder 1029 */ 1030 @NonNull 1031 public Builder resourceTemplateListCachePolicy( 1032 @Nullable McpCachePolicy resourceTemplateListCachePolicy) { 1033 this.resourceTemplateListCachePolicy = 1034 resourceTemplateListCachePolicy == null 1035 ? McpCachePolicy.privateNoCacheInstance() 1036 : resourceTemplateListCachePolicy; 1037 return this; 1038 } 1039 1040 /** 1041 * Sets a named tool-limiter override. 1042 * <p> 1043 * Sequential named and direct setter calls are last-call-wins. This call 1044 * clears any direct limiter previously configured on this builder. 1045 * 1046 * @param toolRateLimiterName nonblank name in the server limiter registry, 1047 * or null to clear the endpoint override 1048 * @return this builder 1049 */ 1050 @NonNull 1051 public Builder toolRateLimiterName(@Nullable String toolRateLimiterName) { 1052 if (toolRateLimiterName == null) { 1053 this.toolRateLimiterName = null; 1054 this.toolRateLimiter = null; 1055 return this; 1056 } 1057 McpContentValueSupport.requireWellFormedString(toolRateLimiterName); 1058 if (toolRateLimiterName.isBlank()) 1059 throw new IllegalArgumentException( 1060 "MCP rate-limiter name must not be blank."); 1061 this.toolRateLimiterName = toolRateLimiterName; 1062 this.toolRateLimiter = null; 1063 return this; 1064 } 1065 1066 /** 1067 * Sets a direct tool-limiter override. 1068 * <p> 1069 * Sequential named and direct setter calls are last-call-wins. This call 1070 * clears any limiter name previously configured on this builder. 1071 * 1072 * @param toolRateLimiter direct tool limiter, or null to clear the endpoint 1073 * override 1074 * @return this builder 1075 */ 1076 @NonNull 1077 public Builder toolRateLimiter(@Nullable McpRateLimiter toolRateLimiter) { 1078 this.toolRateLimiter = toolRateLimiter; 1079 this.toolRateLimiterName = null; 1080 return this; 1081 } 1082 1083 /** 1084 * Sets the endpoint's subscription-change configuration. 1085 * <p> 1086 * Sequential calls are last-call-wins. The immutable configuration and its 1087 * application-owned publisher are retained by reference. 1088 * 1089 * @param subscriptionConfig subscription-change configuration, or null to 1090 * disable subscriptions 1091 * @return this builder 1092 */ 1093 @NonNull 1094 public Builder subscriptionConfig( 1095 @Nullable McpSubscriptionConfig subscriptionConfig) { 1096 this.subscriptionConfig = subscriptionConfig; 1097 return this; 1098 } 1099 1100 /** 1101 * Builds an immutable endpoint. 1102 * <p> 1103 * No tool, prompt, or resource operation is required. Tool and prompt 1104 * names must each be unique within the endpoint, as must exact resource 1105 * URIs and resource URI templates. 1106 * <p> 1107 * Every Apps tool resource association must identify an exact registration 1108 * on this endpoint whose declared MIME type is the Apps HTML profile. 1109 * Templates and custom resource-list descriptors do not establish this 1110 * eligibility. Validation does not invoke application handlers or fetch 1111 * resource contents. 1112 * <p>Skills names occupy distinct standalone/group listing slots. Registered 1113 * descendants must be completely present in every enclosing bundle. Shared 1114 * files must have identical bytes and representation, have at most 16 owners, 1115 * and cannot overlap ordinary exact-resource or URI-template routes. 1116 * Individual Skills responses include endpoint metadata in their output 1117 * preflight. Without a custom Skills-list handler, the complete unfiltered 1118 * automatic page must fit the output profile and contain at most 32 slots. 1119 * 1120 * @return the endpoint 1121 * @throws IllegalStateException if a tool name, prompt name, exact resource URI, 1122 * or resource URI template is duplicated, 1123 * an Apps association has no eligible resource, 1124 * or Skills configuration conflicts or is incomplete 1125 * @throws IllegalArgumentException if a Skills response cannot fit the output 1126 * profile, or the automatic page requires a 1127 * custom {@code skillListHandler(...)} 1128 */ 1129 @NonNull 1130 public McpEndpoint build() { 1131 return new McpEndpoint(this); 1132 } 1133 } 1134}