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.generated.McpGeneratedEndpointProviderLoader; 020import org.jspecify.annotations.NonNull; 021 022import javax.annotation.concurrent.ThreadSafe; 023import java.util.ArrayList; 024import java.util.Arrays; 025import java.util.Collection; 026import java.util.Collections; 027import java.util.IdentityHashMap; 028import java.util.LinkedHashSet; 029import java.util.List; 030import java.util.Map; 031import java.util.Set; 032import java.util.function.UnaryOperator; 033 034import static java.util.Objects.requireNonNull; 035 036/** 037 * An immutable registry of MCP endpoints and their handlers. 038 * <p> 039 * Registry methods preserve endpoint registration order. Every registry 040 * contains at least one endpoint, and no two endpoints may have the same 041 * normalized path. 042 * <p> 043 * Generated endpoints in a named application module require their endpoint 044 * package to be open or exported to Soklet. Packages containing non-public 045 * typed record arguments or results must be open to Soklet for runtime 046 * conversion. 047 * 048 * @author <a href="https://www.revetkn.com">Mark Allen</a> 049 */ 050@ThreadSafe 051public final class McpEndpointRegistry { 052 @NonNull 053 private final List<@NonNull McpEndpoint> endpoints; 054 @NonNull 055 private final Map<@NonNull Class<?>, @NonNull McpEndpoint> 056 generatedEndpoints; 057 058 McpEndpointRegistry( 059 @NonNull Collection<@NonNull McpEndpoint> endpoints) { 060 this(endpoints, Map.of()); 061 } 062 063 McpEndpointRegistry( 064 @NonNull Map<@NonNull Class<?>, @NonNull McpEndpoint> 065 generatedEndpoints) { 066 this(generatedEndpoints.values(), generatedEndpoints); 067 } 068 069 private McpEndpointRegistry( 070 @NonNull Collection<@NonNull McpEndpoint> endpoints, 071 @NonNull Map<@NonNull Class<?>, @NonNull McpEndpoint> 072 generatedEndpoints) { 073 requireNonNull(endpoints); 074 requireNonNull(generatedEndpoints); 075 List<@NonNull McpEndpoint> copiedEndpoints = List.copyOf(endpoints); 076 077 if (copiedEndpoints.isEmpty()) 078 throw new IllegalArgumentException( 079 "At least one MCP endpoint must be configured."); 080 081 Set<@NonNull String> paths = new LinkedHashSet<>(); 082 083 for (McpEndpoint endpoint : copiedEndpoints) { 084 if (!paths.add(endpoint.getPath())) 085 throw new IllegalArgumentException( 086 "Duplicate MCP endpoint path '" + endpoint.getPath() + "'."); 087 } 088 089 Map<Class<?>, McpEndpoint> copiedGeneratedEndpoints = 090 new IdentityHashMap<>(); 091 Set<McpEndpoint> endpointIdentities = Collections.newSetFromMap( 092 new IdentityHashMap<>()); 093 endpointIdentities.addAll(copiedEndpoints); 094 for (Map.Entry<Class<?>, McpEndpoint> generatedEndpoint 095 : generatedEndpoints.entrySet()) { 096 Class<?> endpointClass = requireNonNull(generatedEndpoint.getKey()); 097 McpEndpoint endpoint = requireNonNull(generatedEndpoint.getValue()); 098 if (!endpointIdentities.contains(endpoint)) 099 throw new IllegalArgumentException( 100 "Every generated MCP endpoint must identify a configured endpoint instance."); 101 copiedGeneratedEndpoints.put(endpointClass, endpoint); 102 } 103 104 this.endpoints = copiedEndpoints; 105 this.generatedEndpoints = Collections.unmodifiableMap( 106 copiedGeneratedEndpoints); 107 } 108 109 /** 110 * The endpoints in deterministic registration order. 111 * 112 * @return an immutable endpoint list 113 */ 114 @NonNull 115 public List<@NonNull McpEndpoint> getEndpoints() { 116 return this.endpoints; 117 } 118 119 /** 120 * Returns a registry containing the current endpoints followed by the given 121 * endpoint. 122 * 123 * @param endpoint the endpoint to append 124 * @return a new immutable registry 125 * @throws IllegalArgumentException if the endpoint path is already registered 126 */ 127 @NonNull 128 public McpEndpointRegistry withEndpoint(@NonNull McpEndpoint endpoint) { 129 requireNonNull(endpoint); 130 List<@NonNull McpEndpoint> endpoints = new ArrayList<>(getEndpoints()); 131 endpoints.add(endpoint); 132 return new McpEndpointRegistry(endpoints, this.generatedEndpoints); 133 } 134 135 /** 136 * Returns a registry whose generated endpoint for the supplied annotated 137 * class carries the given subscription-change configuration. 138 * <p> 139 * Soklet selects the already-generated endpoint by the exact loaded 140 * {@link Class} identity retained during generated-descriptor discovery. It 141 * does not initialize the endpoint class, acquire an endpoint instance, or 142 * rediscover handler metadata. Endpoint order and every other endpoint value 143 * are preserved. 144 * 145 * @param annotatedEndpointClass annotated endpoint class whose generated 146 * endpoint is selected 147 * @param subscriptionConfig subscription-change configuration 148 * @return a new immutable registry 149 * @throws IllegalArgumentException if this registry did not load a generated 150 * endpoint for the exact supplied class 151 * @throws NullPointerException if either argument is null 152 */ 153 @NonNull 154 public McpEndpointRegistry withSubscriptionConfig( 155 @NonNull Class<?> annotatedEndpointClass, 156 @NonNull McpSubscriptionConfig subscriptionConfig) { 157 requireNonNull(annotatedEndpointClass); 158 requireNonNull(subscriptionConfig); 159 return withGeneratedEndpoint(annotatedEndpointClass, 160 endpoint -> endpoint.withSubscriptionConfig(subscriptionConfig)); 161 } 162 163 /** 164 * Returns a registry whose generated endpoint for the supplied annotated 165 * class carries the given standalone Skills registrations. 166 * <p> 167 * The list is snapshotted and replaces the complete standalone registration 168 * list; an empty list clears it. Existing Skills groups remain unchanged. 169 * Skills identities, ordinary-resource collisions, and output limits are 170 * validated against the copied endpoint before the new registry is returned. 171 * A failed replacement leaves this registry unchanged. 172 * <p> 173 * Soklet selects the already-generated endpoint by the exact loaded 174 * {@link Class} identity retained during generated-descriptor discovery. It 175 * does not initialize the endpoint class, acquire an endpoint instance, or 176 * rediscover handler metadata. Endpoint order, generated handlers, and every 177 * other endpoint value are preserved. 178 * 179 * @param annotatedEndpointClass annotated endpoint class whose generated 180 * endpoint is selected 181 * @param skillRegistrations complete standalone Skills registration list 182 * @return a new immutable registry 183 * @throws IllegalArgumentException if this registry did not load a generated 184 * endpoint for the exact supplied class, or 185 * the resulting Skills output exceeds its limits 186 * @throws IllegalStateException if Skills identities or files conflict, or an 187 * ordinary resource overlaps a Skills file 188 * @throws NullPointerException if either argument or a list member is null 189 */ 190 @NonNull 191 public McpEndpointRegistry withSkillRegistrations( 192 @NonNull Class<?> annotatedEndpointClass, 193 @NonNull List<@NonNull McpSkillRegistration> skillRegistrations) { 194 requireNonNull(annotatedEndpointClass); 195 requireNonNull(skillRegistrations); 196 return withGeneratedEndpoint(annotatedEndpointClass, 197 endpoint -> endpoint.withSkillRegistrations(skillRegistrations)); 198 } 199 200 /** 201 * Returns a registry whose generated endpoint for the supplied annotated 202 * class carries the given Skills groups. 203 * <p> 204 * The list is snapshotted and replaces the complete group list; an empty 205 * list clears it. Existing standalone Skills registrations remain unchanged. 206 * Grouping does not select a locale or grant access. Skills identities, 207 * ordinary-resource collisions, and output limits are validated against the 208 * copied endpoint before the new registry is returned. A failed replacement 209 * leaves this registry unchanged. 210 * <p> 211 * Soklet selects the already-generated endpoint by the exact loaded 212 * {@link Class} identity retained during generated-descriptor discovery. It 213 * does not initialize the endpoint class, acquire an endpoint instance, or 214 * rediscover handler metadata. Endpoint order, generated handlers, and every 215 * other endpoint value are preserved. 216 * 217 * @param annotatedEndpointClass annotated endpoint class whose generated 218 * endpoint is selected 219 * @param skillGroups complete Skills group list 220 * @return a new immutable registry 221 * @throws IllegalArgumentException if this registry did not load a generated 222 * endpoint for the exact supplied class, or 223 * the resulting Skills output exceeds its limits 224 * @throws IllegalStateException if Skills identities or files conflict, or an 225 * ordinary resource overlaps a Skills file 226 * @throws NullPointerException if either argument or a list member is null 227 */ 228 @NonNull 229 public McpEndpointRegistry withSkillGroups( 230 @NonNull Class<?> annotatedEndpointClass, 231 @NonNull List<@NonNull McpSkillGroup> skillGroups) { 232 requireNonNull(annotatedEndpointClass); 233 requireNonNull(skillGroups); 234 return withGeneratedEndpoint(annotatedEndpointClass, 235 endpoint -> endpoint.withSkillGroups(skillGroups)); 236 } 237 238 @NonNull 239 private McpEndpointRegistry withGeneratedEndpoint( 240 @NonNull Class<?> annotatedEndpointClass, 241 @NonNull UnaryOperator<@NonNull McpEndpoint> replacement) { 242 McpEndpoint generatedEndpoint = this.generatedEndpoints.get( 243 annotatedEndpointClass); 244 if (generatedEndpoint == null) 245 throw new IllegalArgumentException( 246 "No generated MCP endpoint is registered for annotated class '" 247 + annotatedEndpointClass.getName() + "'."); 248 249 List<@NonNull McpEndpoint> endpoints = new ArrayList<>(getEndpoints()); 250 for (int index = 0; index < endpoints.size(); ++index) { 251 McpEndpoint endpoint = endpoints.get(index); 252 if (endpoint == generatedEndpoint) { 253 McpEndpoint replacedEndpoint = replacement.apply(endpoint); 254 endpoints.set(index, replacedEndpoint); 255 Map<Class<?>, McpEndpoint> generatedEndpoints = 256 new IdentityHashMap<>(this.generatedEndpoints); 257 generatedEndpoints.put(annotatedEndpointClass, replacedEndpoint); 258 return new McpEndpointRegistry(endpoints, generatedEndpoints); 259 } 260 } 261 262 throw new IllegalStateException( 263 "Generated MCP endpoint provenance is inconsistent."); 264 } 265 266 /** 267 * Resolves every generated MCP endpoint descriptor visible through the 268 * current thread's context class loader. 269 * 270 * <p>Endpoint classes are not initialized and endpoint instances are not 271 * acquired during descriptor discovery. When a generated handler is invoked, 272 * it acquires its endpoint instance through the 273 * {@link SokletConfig#getInstanceProvider() instance provider} configured on 274 * the {@link SokletConfig} that owns the MCP server. Soklet does not retain or 275 * close the returned endpoint instance. Direct invocation of a generated 276 * registration handler outside a Soklet-managed request uses 277 * {@link InstanceProvider#defaultInstance()}. 278 * 279 * @return an immutable registry ordered by endpoint binary name 280 * @throws IllegalStateException if no generated descriptor is found or an 281 * index or provider is malformed, conflicting, 282 * or cannot be loaded 283 */ 284 @NonNull 285 public static McpEndpointRegistry fromClasspathIntrospection() { 286 ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); 287 if (classLoader == null) 288 classLoader = McpEndpointRegistry.class.getClassLoader(); 289 try { 290 return new McpEndpointRegistry( 291 McpGeneratedEndpointProviderLoader.loadAllWithProvenance( 292 classLoader)); 293 } catch (IllegalArgumentException exception) { 294 throw new IllegalStateException( 295 "Generated MCP endpoint descriptors conflict.", exception); 296 } 297 } 298 299 /** 300 * Resolves generated descriptors for endpoint classes in the supplied order. 301 * 302 * <p>Endpoint classes are not initialized and endpoint instances are not 303 * acquired during descriptor discovery. When a generated handler is invoked, 304 * it acquires its endpoint instance through the 305 * {@link SokletConfig#getInstanceProvider() instance provider} configured on 306 * the {@link SokletConfig} that owns the MCP server. Soklet does not retain or 307 * close the returned endpoint instance. Direct invocation of a generated 308 * registration handler outside a Soklet-managed request uses 309 * {@link InstanceProvider#defaultInstance()}. 310 * 311 * @param endpointClasses one or more annotated endpoint classes 312 * @return an immutable registry in the supplied order 313 * @throws IllegalArgumentException if no classes are supplied, a class is 314 * duplicated, or a class has no generated 315 * descriptor, or two selected endpoints 316 * have the same normalized path 317 * @throws IllegalStateException if an index or provider is malformed, 318 * conflicting, or cannot be loaded 319 * @throws NullPointerException if the array or one of its classes is null 320 */ 321 @NonNull 322 public static McpEndpointRegistry fromClasses( 323 @NonNull Class<?> @NonNull ... endpointClasses) { 324 requireNonNull(endpointClasses); 325 return new McpEndpointRegistry( 326 McpGeneratedEndpointProviderLoader.loadClassesWithProvenance( 327 Arrays.asList(endpointClasses.clone()))); 328 } 329 330 /** 331 * Creates an immutable registry from endpoints in registration order. 332 * 333 * @param endpoints one or more endpoints 334 * @return an immutable endpoint registry 335 * @throws IllegalArgumentException if no endpoints are supplied or two 336 * endpoints have the same normalized path 337 */ 338 @NonNull 339 public static McpEndpointRegistry fromEndpoints( 340 @NonNull Collection<@NonNull McpEndpoint> endpoints) { 341 return new McpEndpointRegistry(endpoints); 342 } 343}