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.time.Duration; 025import java.util.List; 026import java.util.Objects; 027import java.util.Optional; 028 029import static java.util.Objects.requireNonNull; 030import static com.soklet.internal.mcp.protocol.McpApplicationMetadata.requireApplicationMetadata; 031 032/** 033 * One immutable complete {@code resources/list} page. 034 * 035 * <p>The endpoint's resources-list cache policy owns the fixed cache scope and 036 * default time to live. A page may override only the time to live. The page is 037 * returned directly and does not require a {@link McpCompleteResult} wrapper. 038 * A localization-enabled server conservatively publishes a private, 039 * zero-time-to-live policy regardless of a page override. 040 * 041 * @author <a href="https://www.revetkn.com">Mark Allen</a> 042 */ 043@ThreadSafe 044public final class McpResourcePage implements McpOperationResult { 045 @NonNull 046 private final List<@NonNull McpResourceDescriptor> resourceDescriptors; 047 @NonNull 048 private final McpJsonObject metadata; 049 @Nullable 050 private final String nextCursor; 051 @Nullable 052 private final Duration cacheTimeToLiveOverride; 053 054 /** @return an empty resource-page builder */ 055 @NonNull 056 public static Builder builder() { 057 return new Builder(); 058 } 059 060 private McpResourcePage(@NonNull Builder builder) { 061 this.resourceDescriptors = List.copyOf(builder.resourceDescriptors); 062 this.metadata = requireApplicationMetadata(builder.metadata); 063 this.nextCursor = builder.nextCursor; 064 this.cacheTimeToLiveOverride = builder.cacheTimeToLiveOverride; 065 } 066 067 /** @return immutable resource descriptors in insertion order */ 068 @NonNull 069 public List<@NonNull McpResourceDescriptor> getResourceDescriptors() { 070 return this.resourceDescriptors; 071 } 072 073 /** @return immutable result-level protocol extension metadata */ 074 @NonNull 075 public McpJsonObject getMetadata() { 076 return this.metadata; 077 } 078 079 /** 080 * Returns the opaque cursor clients should supply for the next page. 081 * 082 * <p>The application is responsible for binding a cursor to the intended 083 * page position, retained snapshot and catalog revision, authorization 084 * context, and expiry as required by its deployment. Soklet does not sign, 085 * encrypt, store, or verify the cursor. 086 * 087 * @return next cursor, or empty when this is the final page 088 */ 089 @NonNull 090 public Optional<@NonNull String> getNextCursor() { 091 return Optional.ofNullable(this.nextCursor); 092 } 093 094 /** 095 * Returns this page's time-to-live override. 096 * 097 * @return override, or empty to use the endpoint default 098 */ 099 @NonNull 100 public Optional<@NonNull Duration> getCacheTimeToLiveOverride() { 101 return Optional.ofNullable(this.cacheTimeToLiveOverride); 102 } 103 104 /** @return whether every page property is structurally equal */ 105 @Override 106 public boolean equals(@Nullable Object other) { 107 if (this == other) 108 return true; 109 if (!(other instanceof McpResourcePage page)) 110 return false; 111 return this.resourceDescriptors.equals(page.resourceDescriptors) 112 && this.metadata.equals(page.metadata) 113 && Objects.equals(this.nextCursor, page.nextCursor) 114 && Objects.equals(this.cacheTimeToLiveOverride, 115 page.cacheTimeToLiveOverride); 116 } 117 118 /** @return structural page hash code */ 119 @Override 120 public int hashCode() { 121 return Objects.hash(this.resourceDescriptors, this.metadata, this.nextCursor, 122 this.cacheTimeToLiveOverride); 123 } 124 125 /** 126 * Mutable builder for an immutable resource page. 127 * 128 * @author <a href="https://www.revetkn.com">Mark Allen</a> 129 */ 130 @NotThreadSafe 131 public static final class Builder { 132 @NonNull 133 private List<@NonNull McpResourceDescriptor> resourceDescriptors = 134 List.of(); 135 @NonNull 136 private McpJsonObject metadata = McpJsonObject.emptyInstance(); 137 @Nullable 138 private String nextCursor; 139 @Nullable 140 private Duration cacheTimeToLiveOverride; 141 142 private Builder() { 143 } 144 145 /** 146 * Replaces resource descriptors in supplied order. 147 * Null or empty clears the property. The complete list is validated and 148 * snapshotted before replacing the prior value. 149 * 150 * @param resourceDescriptors resource descriptors, or null to clear 151 * @return this builder 152 * @throws NullPointerException if a list element is null 153 */ 154 @NonNull 155 public Builder resourceDescriptors( 156 @Nullable List<@NonNull McpResourceDescriptor> resourceDescriptors) { 157 this.resourceDescriptors = resourceDescriptors == null ? List.of() 158 : List.copyOf(resourceDescriptors); 159 return this; 160 } 161 162 /** @param metadata result-level protocol extension metadata 163 * @return this builder */ 164 @NonNull 165 public Builder metadata(@NonNull McpJsonObject metadata) { 166 this.metadata = requireNonNull(metadata); 167 return this; 168 } 169 170 /** 171 * Supplies the opaque cursor clients should use for the next page. 172 * 173 * <p>The application owns the cursor's integrity, confidentiality, 174 * authorization binding, expiry, page position, retained snapshot, 175 * catalog revision, and cross-instance portability. An empty string 176 * remains a present protocol value. Soklet enforces its configured 177 * outgoing UTF-8 bound but does not mint or protect the value. 178 * 179 * @param nextCursor next cursor 180 * @return this builder 181 * @throws IllegalArgumentException if the cursor contains malformed UTF-16 182 */ 183 @NonNull 184 public Builder nextCursor(@NonNull String nextCursor) { 185 this.nextCursor = McpContentValueSupport.requireWellFormedString(requireNonNull(nextCursor)); 186 return this; 187 } 188 189 /** 190 * Overrides only the endpoint's default time to live for this page. 191 * 192 * @param cacheTimeToLiveOverride nonnegative whole-millisecond duration 193 * @return this builder 194 * @throws IllegalArgumentException if the duration is negative or has 195 * sub-millisecond precision 196 */ 197 @NonNull 198 public Builder cacheTimeToLiveOverride( 199 @NonNull Duration cacheTimeToLiveOverride) { 200 this.cacheTimeToLiveOverride = 201 McpCachePolicy.requireTimeToLive(cacheTimeToLiveOverride); 202 return this; 203 } 204 205 /** @return immutable resource page */ 206 @NonNull 207 public McpResourcePage build() { 208 return new McpResourcePage(this); 209 } 210 } 211}