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}