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.annotation.DELETE;
020import com.soklet.annotation.GET;
021import com.soklet.annotation.HEAD;
022import com.soklet.annotation.McpListResources;
023import com.soklet.annotation.McpPrompt;
024import com.soklet.annotation.McpResource;
025import com.soklet.annotation.McpServerEndpoint;
026import com.soklet.annotation.McpTool;
027import com.soklet.annotation.OPTIONS;
028import com.soklet.annotation.PATCH;
029import com.soklet.annotation.POST;
030import com.soklet.annotation.PUT;
031import com.soklet.annotation.SseEventSource;
032import com.google.errorprone.annotations.FormatMethod;
033import org.jspecify.annotations.NonNull;
034
035import javax.annotation.concurrent.NotThreadSafe;
036import javax.annotation.processing.AbstractProcessor;
037import javax.annotation.processing.Filer;
038import javax.annotation.processing.FilerException;
039import javax.annotation.processing.Messager;
040import javax.annotation.processing.ProcessingEnvironment;
041import javax.annotation.processing.RoundEnvironment;
042import javax.lang.model.SourceVersion;
043import javax.lang.model.element.AnnotationMirror;
044import javax.lang.model.element.AnnotationValue;
045import javax.lang.model.element.Element;
046import javax.lang.model.element.ElementKind;
047import javax.lang.model.element.ExecutableElement;
048import javax.lang.model.element.Modifier;
049import javax.lang.model.element.TypeElement;
050import javax.lang.model.element.VariableElement;
051import javax.lang.model.type.TypeMirror;
052import javax.lang.model.util.Elements;
053import javax.lang.model.util.Types;
054import javax.tools.Diagnostic;
055import javax.tools.FileObject;
056import javax.tools.StandardLocation;
057import java.io.BufferedReader;
058import java.io.IOException;
059import java.io.InputStreamReader;
060import java.io.UncheckedIOException;
061import java.io.Writer;
062import java.lang.annotation.Annotation;
063import java.lang.annotation.Repeatable;
064import java.net.URI;
065import java.nio.charset.StandardCharsets;
066import java.nio.file.AtomicMoveNotSupportedException;
067import java.nio.file.Files;
068import java.nio.file.Path;
069import java.nio.file.Paths;
070import java.nio.file.StandardCopyOption;
071import java.security.MessageDigest;
072import java.security.NoSuchAlgorithmException;
073import java.util.ArrayList;
074import java.util.Arrays;
075import java.util.Base64;
076import java.util.Collections;
077import java.util.Comparator;
078import java.util.LinkedHashMap;
079import java.util.LinkedHashSet;
080import java.util.List;
081import java.util.Locale;
082import java.util.Map;
083import java.util.Set;
084import java.util.stream.Collectors;
085
086/**
087 * Soklet's standard Annotation Processor which is used to generate lookup tables of <em>Resource Method</em> definitions at compile time as well as prevent usage errors that are detectable by static analysis.
088 * <p>
089 * This Annotation Processor ensures <em>Resource Methods</em> annotated with {@link SseEventSource} are declared as returning an instance of {@link SseHandshakeResult}.
090 * <p>
091 * Your build system should ensure this Annotation Processor is available at compile time. Follow the instructions below to make your application conformant:
092 * <p>
093 * Using {@code javac} directly:
094 * <pre>javac -parameters -processor com.soklet.SokletProcessor ...[rest of javac command elided]</pre>
095 * Using <a href="https://maven.apache.org" target="_blank">Maven</a>:
096 * <pre>{@code <plugin>
097 *     <groupId>org.apache.maven.plugins</groupId>
098 *     <artifactId>maven-compiler-plugin</artifactId>
099 *     <version>...</version>
100 *     <configuration>
101 *         <release>...</release>
102 *         <compilerArgs>
103 *             <!-- Rest of args elided -->
104 *             <arg>-parameters</arg>
105 *             <arg>-processor</arg>
106 *             <arg>com.soklet.SokletProcessor</arg>
107 *         </compilerArgs>
108 *     </configuration>
109 * </plugin>}</pre>
110 * Using <a href="https://gradle.org" target="_blank">Gradle</a>:
111 * <pre>{@code def sokletVersion = "3.3.0" // (use your actual version)
112 *
113 * dependencies {
114 *   // Soklet used by your code at compile/run time
115 *   implementation "com.soklet:soklet:${sokletVersion}"
116 *
117 *   // Same artifact also provides the annotation processor
118 *   annotationProcessor "com.soklet:soklet:${sokletVersion}"
119 *
120 *   // If tests also need processing (optional)
121 *   testAnnotationProcessor "com.soklet:soklet:${sokletVersion}"
122 * }}</pre>
123 *
124 * <p><strong>Incremental/IDE ("IntelliJ-safe") behavior</strong>
125 * <ul>
126 *   <li>Never rebuilds the global index from only the currently-compiled sources. It always merges with the prior index.</li>
127 *   <li>Only removes stale entries for top-level types compiled in the current compiler invocation (touched types).</li>
128 *   <li>Skips writing the index entirely if compilation errors are present, preventing clobbering a good index.</li>
129 *   <li>Writes with originating elements (best-effort) so incremental build tools can track dependencies.</li>
130 * </ul>
131 *
132 * <p><strong>Processor options</strong>
133 * <ul>
134 *   <li><code>-Asoklet.cacheMode=none|sidecar|persistent</code> (default: <code>sidecar</code>)</li>
135 *   <li><code>-Asoklet.cacheDir=/path</code> (used only when cacheMode=persistent; required to enable persistent)</li>
136 *   <li><code>-Asoklet.pruneDeleted=true|false</code> (default: false; generally not IDE-safe)</li>
137 *   <li><code>-Asoklet.debug=true|false</code> (default: false)</li>
138 * </ul>
139 *
140 * <p><strong>Important</strong>: This processor will never create a project-root <code>.soklet</code> directory by default.
141 * Persistent caching is only enabled when <code>cacheMode=persistent</code> <em>and</em> <code>soklet.cacheDir</code> is set.
142 *
143 * @author <a href="https://www.revetkn.com">Mark Allen</a>
144 */
145@NotThreadSafe
146public final class SokletProcessor extends AbstractProcessor {
147        // ---- Options ------------------------------------------------------------
148
149        private static final String PROCESSOR_OPTION_CACHE_MODE = "soklet.cacheMode";
150        private static final String PROCESSOR_OPTION_CACHE_DIR = "soklet.cacheDir";
151        private static final String PROCESSOR_OPTION_PRUNE_DELETED = "soklet.pruneDeleted";
152        private static final String PROCESSOR_OPTION_DEBUG = "soklet.debug";
153
154        private static final String PERSISTENT_CACHE_INDEX_DIR = "resource-methods";
155        private static final String MCP_PERSISTENT_CACHE_INDEX_DIR = "mcp-endpoints";
156
157        // ---- Index paths ---------------------------------------------------------
158
159        static final String RESOURCE_METHOD_LOOKUP_TABLE_PATH = "META-INF/soklet/resource-method-lookup-table";
160        static final String MCP_ENDPOINT_LOOKUP_TABLE_PATH = "META-INF/soklet/mcp-endpoint-lookup-table";
161        private static final String OUTPUT_ROOT_MARKER_PATH = "META-INF/soklet/.soklet-output-root";
162
163        private static final String SIDE_CAR_DIR_NAME = "soklet";
164        private static final String SIDE_CAR_INDEX_FILENAME = "resource-method-lookup-table";
165        private static final String MCP_SIDE_CAR_INDEX_FILENAME = "mcp-endpoint-lookup-table";
166
167        // ---- JSR-269 services ----------------------------------------------------
168
169        private Types types;
170        private Elements elements;
171        private Messager messager;
172        private Filer filer;
173
174        private boolean debugEnabled;
175        private boolean pruneDeletedEnabled;
176        private CacheMode cacheMode;
177
178        // Cached mirrors resolved in init()
179        private TypeMirror sseHandshakeResultType;   // com.soklet.SseHandshakeResult
180        private TypeMirror mcpToolResultType;        // com.soklet.McpToolResult
181        private TypeMirror mcpPromptResultType;      // com.soklet.McpPromptResult
182        private TypeMirror mcpResourceContentsType;  // com.soklet.McpResourceContents
183        private TypeMirror mcpListResourcesResultType; // com.soklet.McpListResourcesResult
184        private TypeElement pathParameterElement;    // com.soklet.annotation.PathParameter
185        private TypeElement mcpEndpointElement;      // com.soklet.McpEndpoint
186
187        // Collected during this compilation invocation
188        private final List<ResourceMethodDeclaration> collected = new ArrayList<>();
189        private final List<McpEndpointDeclaration> collectedMcpEndpoints = new ArrayList<>();
190        private final Set<String> touchedTopLevelBinaries = new LinkedHashSet<>();
191        private boolean resourceMethodAmbiguityDetected;
192
193        // ---- Supported annotations ----------------------------------------------
194
195        private static final List<Class<? extends Annotation>> HTTP_AND_SSE_ANNOTATIONS = List.of(
196                        GET.class, POST.class, PUT.class, PATCH.class, DELETE.class, HEAD.class, OPTIONS.class,
197                        SseEventSource.class
198        );
199        private static final List<Class<? extends Annotation>> MCP_ANNOTATIONS = List.of(
200                        McpServerEndpoint.class, McpTool.class, McpPrompt.class, McpResource.class, McpListResources.class
201        );
202
203        // ---- Cache modes ---------------------------------------------------------
204
205        private enum CacheMode {
206                NONE,       // Only CLASS_OUTPUT index. No sidecar/persistent. Lowest clutter, lowest resiliency.
207                SIDECAR,    // CLASS_OUTPUT + sidecar (under the class output parent directory). Default.
208                PERSISTENT  // CLASS_OUTPUT + sidecar + persistent (under soklet.cacheDir). Requires soklet.cacheDir.
209        }
210
211        @Override
212        public synchronized void init(ProcessingEnvironment processingEnv) {
213                super.init(processingEnv);
214                this.types = processingEnv.getTypeUtils();
215                this.elements = processingEnv.getElementUtils();
216                this.messager = processingEnv.getMessager();
217                this.filer = processingEnv.getFiler();
218
219                this.debugEnabled = parseBooleanishOption(processingEnv.getOptions().get(PROCESSOR_OPTION_DEBUG));
220                this.pruneDeletedEnabled = parseBooleanishOption(processingEnv.getOptions().get(PROCESSOR_OPTION_PRUNE_DELETED));
221                this.cacheMode = parseCacheMode(processingEnv.getOptions().get(PROCESSOR_OPTION_CACHE_MODE));
222
223                        TypeElement hr = elements.getTypeElement("com.soklet.SseHandshakeResult");
224                        this.sseHandshakeResultType = (hr == null ? null : hr.asType());
225                        TypeElement mcpToolResult = elements.getTypeElement("com.soklet.McpToolResult");
226                        this.mcpToolResultType = (mcpToolResult == null ? null : mcpToolResult.asType());
227                        TypeElement mcpPromptResult = elements.getTypeElement("com.soklet.McpPromptResult");
228                        this.mcpPromptResultType = (mcpPromptResult == null ? null : mcpPromptResult.asType());
229                        TypeElement mcpResourceContents = elements.getTypeElement("com.soklet.McpResourceContents");
230                        this.mcpResourceContentsType = (mcpResourceContents == null ? null : mcpResourceContents.asType());
231                        TypeElement mcpListResourcesResult = elements.getTypeElement("com.soklet.McpListResourcesResult");
232                        this.mcpListResourcesResultType = (mcpListResourcesResult == null ? null : mcpListResourcesResult.asType());
233                        this.pathParameterElement = elements.getTypeElement("com.soklet.annotation.PathParameter");
234                        this.mcpEndpointElement = elements.getTypeElement("com.soklet.McpEndpoint");
235
236                // If persistent mode was requested but cacheDir isn't configured, downgrade to SIDECAR.
237                if (this.cacheMode == CacheMode.PERSISTENT && persistentCacheRoot() == null) {
238                        debug("SokletProcessor: cacheMode=persistent requested but %s not set/invalid; falling back to sidecar.",
239                                        PROCESSOR_OPTION_CACHE_DIR);
240                        this.cacheMode = CacheMode.SIDECAR;
241                }
242        }
243
244        @Override
245        public Set<String> getSupportedAnnotationTypes() {
246                Set<String> out = new LinkedHashSet<>();
247                for (Class<? extends Annotation> c : HTTP_AND_SSE_ANNOTATIONS) {
248                        out.add(c.getCanonicalName());
249                        Class<? extends Annotation> container = findRepeatableContainer(c);
250                        if (container != null) out.add(container.getCanonicalName());
251                }
252                for (Class<? extends Annotation> c : MCP_ANNOTATIONS)
253                        out.add(c.getCanonicalName());
254                return out;
255        }
256
257        @Override
258        public SourceVersion getSupportedSourceVersion() {
259                return SourceVersion.latestSupported();
260        }
261
262        @Override
263        public Set<String> getSupportedOptions() {
264                return new LinkedHashSet<>(List.of(
265                                PROCESSOR_OPTION_CACHE_MODE,
266                                PROCESSOR_OPTION_CACHE_DIR,
267                                PROCESSOR_OPTION_PRUNE_DELETED,
268                                PROCESSOR_OPTION_DEBUG
269                ));
270        }
271
272        @Override
273        public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
274                // Track top-level types being compiled in this invocation.
275                for (Element root : roundEnv.getRootElements()) {
276                        if (root instanceof TypeElement te) {
277                                String bin = elements.getBinaryName(te).toString();
278                                touchedTopLevelBinaries.add(bin);
279                        }
280                }
281
282                        // SSE-specific return type check
283                        enforceSseReturnTypes(roundEnv);
284                        enforceMcpReturnTypes(roundEnv);
285
286                // Collect + validate
287                collect(roundEnv, HttpMethod.GET, GET.class, false);
288                collect(roundEnv, HttpMethod.POST, POST.class, false);
289                collect(roundEnv, HttpMethod.PUT, PUT.class, false);
290                collect(roundEnv, HttpMethod.PATCH, PATCH.class, false);
291                collect(roundEnv, HttpMethod.DELETE, DELETE.class, false);
292                collect(roundEnv, HttpMethod.HEAD, HEAD.class, false);
293                collect(roundEnv, HttpMethod.OPTIONS, OPTIONS.class, false);
294                collect(roundEnv, HttpMethod.GET, SseEventSource.class, true); // SSE as GET + flag
295                collectMcpEndpoints(roundEnv);
296
297                if (roundEnv.processingOver()) {
298                        // Critical: don't overwrite a good index with a partial/failed compile.
299                        if (roundEnv.errorRaised() || resourceMethodAmbiguityDetected) {
300                                debug("SokletProcessor: compilation has errors; skipping index write to avoid clobbering.");
301                                return false;
302                        }
303                        mergeAndWriteIndex(collected, touchedTopLevelBinaries);
304                        mergeAndWriteMcpIndex(collectedMcpEndpoints, touchedTopLevelBinaries);
305                }
306
307                return false;
308        }
309
310        /**
311         * Collects and validates each annotated method occurrence (repeatable-aware, without reflection).
312         */
313        private void collect(RoundEnvironment roundEnv,
314                                                                                         HttpMethod httpMethod,
315                                                                                         Class<? extends Annotation> baseAnnotation,
316                                                                                         boolean sseEventSource) {
317
318                TypeElement base = elements.getTypeElement(baseAnnotation.getCanonicalName());
319                Class<? extends Annotation> containerClass = findRepeatableContainer(baseAnnotation);
320                TypeElement container = containerClass == null ? null : elements.getTypeElement(containerClass.getCanonicalName());
321
322                Set<Element> candidates = new LinkedHashSet<>();
323                if (base != null) candidates.addAll(roundEnv.getElementsAnnotatedWith(base));
324                if (container != null) candidates.addAll(roundEnv.getElementsAnnotatedWith(container));
325
326                for (Element e : candidates) {
327                        if (e.getKind() != ElementKind.METHOD) {
328                                error(e, "Soklet: @%s can only be applied to methods.", baseAnnotation.getSimpleName());
329                                continue;
330                        }
331
332                        ExecutableElement method = (ExecutableElement) e;
333                        TypeElement owner = (TypeElement) method.getEnclosingElement();
334
335                        boolean isPublic = method.getModifiers().contains(Modifier.PUBLIC);
336                        boolean isStatic = method.getModifiers().contains(Modifier.STATIC);
337
338                        if (isStatic) error(method, "Soklet: Resource Method must not be static");
339                        if (!isPublic) error(method, "Soklet: Resource Method must be public");
340
341                        // Extract each occurrence as an AnnotationMirror (handles repeatable containers)
342                        List<AnnotationMirror> occurrences = extractOccurrences(method, base, container);
343
344                        for (AnnotationMirror annMirror : occurrences) {
345                                String rawPath = readAnnotationStringMember(annMirror, "value");
346                                if (rawPath == null || rawPath.isBlank()) {
347                                        error(method, "Soklet: @%s must have a non-empty path value", baseAnnotation.getSimpleName());
348                                        continue;
349                                }
350
351                                String path = normalizePath(rawPath);
352
353                                ValidationResult vr = validatePathTemplate(method, path);
354                                if (!vr.ok) continue;
355
356                                ParamBindings pb = readPathParameterBindings(method);
357
358                                // a) placeholders must be bound
359                                for (String placeholder : vr.placeholders) {
360                                        if (!pb.paramNames.contains(placeholder)) {
361                                                String shown = vr.original.getOrDefault(placeholder, placeholder);
362                                                error(method, "Resource Method path parameter {%s} not bound to a @PathParameter argument", shown);
363                                        }
364                                }
365
366                                // b) annotated params must exist in template
367                                for (String annotated : pb.paramNames) {
368                                        if (!vr.placeholders.contains(annotated)) {
369                                                error(method, "No placeholder {%s} present in resource path declaration", annotated);
370                                        }
371                                }
372
373                                // Only collect if this method is otherwise valid
374                                if (!pb.hadError && vr.ok && isPublic && !isStatic) {
375                                        String className = elements.getBinaryName(owner).toString();
376                                        String methodName = method.getSimpleName().toString();
377                                        String[] paramTypes = method.getParameters().stream()
378                                                        .map(p -> jvmTypeName(p.asType()))
379                                                        .toArray(String[]::new);
380
381                                        ResourceMethodDeclaration declaration = new ResourceMethodDeclaration(
382                                                        httpMethod, path, className, methodName, paramTypes, sseEventSource
383                                        );
384                                        detectResourceMethodAmbiguity(method, declaration);
385                                        collected.add(declaration);
386                                }
387                        }
388                }
389        }
390
391        private List<AnnotationMirror> extractOccurrences(ExecutableElement method, TypeElement base, TypeElement container) {
392                List<AnnotationMirror> out = new ArrayList<>();
393
394                for (AnnotationMirror am : method.getAnnotationMirrors()) {
395                        if (base != null && isAnnotationType(am, base)) {
396                                out.add(am);
397                        } else if (container != null && isAnnotationType(am, container)) {
398                                Object v = readAnnotationMemberValue(am, "value");
399                                if (v instanceof List<?> list) {
400                                        for (Object o : list) {
401                                                if (o instanceof AnnotationValue av) {
402                                                        Object inner = av.getValue();
403                                                        if (inner instanceof AnnotationMirror innerAm) {
404                                                                out.add(innerAm);
405                                                        }
406                                                }
407                                        }
408                                }
409                        }
410                }
411
412                return out;
413        }
414
415        // --- Helpers for parameter annotations ------------------------------------
416
417        private static final class ParamBindings {
418                final Set<String> paramNames;
419                final boolean hadError;
420
421                ParamBindings(Set<String> names, boolean hadError) {
422                        this.paramNames = names;
423                        this.hadError = hadError;
424                }
425        }
426
427        private ParamBindings readPathParameterBindings(ExecutableElement method) {
428                boolean hadError = false;
429                Set<String> names = new LinkedHashSet<>();
430                if (pathParameterElement == null) return new ParamBindings(names, false);
431
432                for (VariableElement p : method.getParameters()) {
433                        for (AnnotationMirror am : p.getAnnotationMirrors()) {
434                                if (isAnnotationType(am, pathParameterElement)) {
435                                        // 1) try explicit annotation member
436                                        String name = readAnnotationStringMember(am, "name");
437                                        // 2) default to the parameter's source name if missing/blank
438                                        if (name == null || name.isBlank()) {
439                                                name = p.getSimpleName().toString();
440                                        }
441                                        if (name != null && !name.isBlank()) {
442                                                names.add(name);
443                                        }
444                                }
445                        }
446                }
447
448                return new ParamBindings(names, hadError);
449        }
450
451        private static boolean isAnnotationType(AnnotationMirror am, TypeElement type) {
452                return am.getAnnotationType().asElement().equals(type);
453        }
454
455        private static Object readAnnotationMemberValue(AnnotationMirror am, String member) {
456                for (Map.Entry<? extends ExecutableElement, ? extends AnnotationValue> e : am.getElementValues().entrySet()) {
457                        if (e.getKey().getSimpleName().contentEquals(member)) {
458                                return e.getValue().getValue();
459                        }
460                }
461                return null;
462        }
463
464        private static String readAnnotationStringMember(AnnotationMirror am, String member) {
465                Object v = readAnnotationMemberValue(am, member);
466                return (v == null) ? null : v.toString();
467        }
468
469        // --- Path parsing/validation ----------------------------------------------
470
471        private static final class ValidationResult {
472                final boolean ok;
473                final Set<String> placeholders;       // normalized names (no trailing '*')
474                final Map<String, String> original;   // normalized -> original token
475
476                ValidationResult(boolean ok, Set<String> placeholders, Map<String, String> original) {
477                        this.ok = ok;
478                        this.placeholders = placeholders;
479                        this.original = original;
480                }
481        }
482
483        /**
484         * Validates braces and duplicate placeholders (treating {name*} as a greedy/varargs placeholder whose
485         * logical name is "name"). Duplicate detection is done on the normalized name (without trailing '*').
486         */
487        private ValidationResult validatePathTemplate(Element reportOn, String path) {
488                if (path == null || path.isEmpty()) {
489                        return new ValidationResult(false, Collections.emptySet(), Collections.emptyMap());
490                }
491
492                Set<String> names = new LinkedHashSet<>();
493                Map<String, String> originalTokens = new LinkedHashMap<>();
494
495                int i = 0;
496                while (i < path.length()) {
497                        char c = path.charAt(i);
498                        if (c == '{') {
499                                int close = path.indexOf('}', i + 1);
500                                if (close < 0) {
501                                        error(reportOn, "Soklet: Malformed resource path declaration (unbalanced braces)");
502                                        return new ValidationResult(false, Collections.emptySet(), Collections.emptyMap());
503                                }
504
505                                String token = path.substring(i + 1, close);   // e.g., "id", "cssPath*"
506                                if (token.isEmpty()) {
507                                        error(reportOn, "Soklet: Malformed resource path declaration (unbalanced braces)");
508                                        return new ValidationResult(false, Collections.emptySet(), Collections.emptyMap());
509                                }
510
511                                String normalized = normalizePlaceholder(token);
512                                if (normalized.isEmpty()) {
513                                        error(reportOn, "Soklet: Malformed resource path declaration (unbalanced braces)");
514                                        return new ValidationResult(false, Collections.emptySet(), Collections.emptyMap());
515                                }
516
517                                if (!names.add(normalized)) {
518                                        error(reportOn, "Soklet: Duplicate @PathParameter name: %s", normalized);
519                                }
520                                originalTokens.putIfAbsent(normalized, token);
521
522                                i = close + 1;
523                        } else if (c == '}') {
524                                error(reportOn, "Soklet: Malformed resource path declaration (unbalanced braces)");
525                                return new ValidationResult(false, Collections.emptySet(), Collections.emptyMap());
526                        } else {
527                                i++;
528                        }
529                }
530
531                return new ValidationResult(true, names, originalTokens);
532        }
533
534        private static String normalizePlaceholder(String token) {
535                if (token.endsWith("*")) return token.substring(0, token.length() - 1);
536                return token;
537        }
538
539        // --- MCP collection/validation --------------------------------------------
540
541        private void collectMcpEndpoints(RoundEnvironment roundEnv) {
542                TypeElement mcpServerEndpointType = elements.getTypeElement(McpServerEndpoint.class.getCanonicalName());
543
544                if (mcpServerEndpointType != null) {
545                        for (Element element : roundEnv.getElementsAnnotatedWith(mcpServerEndpointType)) {
546                                if (element.getKind() != ElementKind.CLASS) {
547                                        error(element, "Soklet: @%s can only be applied to classes.", McpServerEndpoint.class.getSimpleName());
548                                        continue;
549                                }
550
551                                validateAndCollectMcpEndpoint((TypeElement) element);
552                        }
553                }
554
555                validateMcpAnnotatedMethodsBelongToEndpoint(roundEnv, McpTool.class);
556                validateMcpAnnotatedMethodsBelongToEndpoint(roundEnv, McpPrompt.class);
557                validateMcpAnnotatedMethodsBelongToEndpoint(roundEnv, McpResource.class);
558                validateMcpAnnotatedMethodsBelongToEndpoint(roundEnv, McpListResources.class);
559        }
560
561        private void validateAndCollectMcpEndpoint(TypeElement endpointType) {
562                boolean valid = true;
563
564                if (mcpEndpointElement == null || !types.isAssignable(endpointType.asType(), mcpEndpointElement.asType())) {
565                        error(endpointType, "Soklet: Classes annotated with @%s must implement %s.",
566                                        McpServerEndpoint.class.getSimpleName(), McpEndpoint.class.getSimpleName());
567                        valid = false;
568                }
569
570                McpServerEndpoint endpoint = endpointType.getAnnotation(McpServerEndpoint.class);
571
572                if (endpoint == null)
573                        return;
574
575                if (endpoint.path().isBlank()) {
576                        error(endpointType, "Soklet: @%s path must be non-empty", McpServerEndpoint.class.getSimpleName());
577                        valid = false;
578                } else {
579                        ValidationResult validationResult = validatePathTemplate(endpointType, normalizePath(endpoint.path()));
580                        valid = valid && validationResult.ok;
581                }
582
583                if (endpoint.name().isBlank()) {
584                        error(endpointType, "Soklet: @%s name must be non-empty", McpServerEndpoint.class.getSimpleName());
585                        valid = false;
586                }
587
588                if (endpoint.version().isBlank()) {
589                        error(endpointType, "Soklet: @%s version must be non-empty", McpServerEndpoint.class.getSimpleName());
590                        valid = false;
591                }
592
593                String websiteUrl = endpoint.websiteUrl();
594                if (websiteUrl != null && !websiteUrl.isBlank()
595                                && !(websiteUrl.startsWith("https://") || websiteUrl.startsWith("http://"))) {
596                        error(endpointType, "Soklet: @%s websiteUrl must start with http:// or https://", McpServerEndpoint.class.getSimpleName());
597                        valid = false;
598                }
599
600                Set<String> toolNames = new LinkedHashSet<>();
601                Set<String> promptNames = new LinkedHashSet<>();
602                Set<String> resourceUris = new LinkedHashSet<>();
603                Set<String> resourceNames = new LinkedHashSet<>();
604                int resourceListMethodCount = 0;
605
606                for (Element enclosedElement : endpointType.getEnclosedElements()) {
607                        if (enclosedElement.getKind() != ElementKind.METHOD)
608                                continue;
609
610                        ExecutableElement method = (ExecutableElement) enclosedElement;
611
612                        if (method.getAnnotation(McpTool.class) != null) {
613                                if (!validateMcpAnnotatedMethod(method, McpTool.class.getSimpleName()))
614                                        valid = false;
615
616                                McpTool tool = method.getAnnotation(McpTool.class);
617
618                                if (tool.name().isBlank()) {
619                                        error(method, "Soklet: @%s name must be non-empty", McpTool.class.getSimpleName());
620                                        valid = false;
621                                } else if (!toolNames.add(tool.name())) {
622                                        error(method, "Soklet: Duplicate MCP tool name '%s'", tool.name());
623                                        valid = false;
624                                }
625
626                                if (tool.description().isBlank()) {
627                                        error(method, "Soklet: @%s description must be non-empty", McpTool.class.getSimpleName());
628                                        valid = false;
629                                }
630                        }
631
632                        if (method.getAnnotation(McpPrompt.class) != null) {
633                                if (!validateMcpAnnotatedMethod(method, McpPrompt.class.getSimpleName()))
634                                        valid = false;
635
636                                McpPrompt prompt = method.getAnnotation(McpPrompt.class);
637
638                                if (prompt.name().isBlank()) {
639                                        error(method, "Soklet: @%s name must be non-empty", McpPrompt.class.getSimpleName());
640                                        valid = false;
641                                } else if (!promptNames.add(prompt.name())) {
642                                        error(method, "Soklet: Duplicate MCP prompt name '%s'", prompt.name());
643                                        valid = false;
644                                }
645
646                                if (prompt.description().isBlank()) {
647                                        error(method, "Soklet: @%s description must be non-empty", McpPrompt.class.getSimpleName());
648                                        valid = false;
649                                }
650                        }
651
652                        if (method.getAnnotation(McpResource.class) != null) {
653                                if (!validateMcpAnnotatedMethod(method, McpResource.class.getSimpleName()))
654                                        valid = false;
655
656                                McpResource resource = method.getAnnotation(McpResource.class);
657
658                                if (resource.uri().isBlank()) {
659                                        error(method, "Soklet: @%s uri must be non-empty", McpResource.class.getSimpleName());
660                                        valid = false;
661                                } else {
662                                        ValidationResult validationResult = validatePathTemplate(method, resource.uri());
663                                        valid = valid && validationResult.ok;
664
665                                        if (!resourceUris.add(resource.uri())) {
666                                                error(method, "Soklet: Duplicate MCP resource URI '%s'", resource.uri());
667                                                valid = false;
668                                        }
669                                }
670
671                                if (resource.name().isBlank()) {
672                                        error(method, "Soklet: @%s name must be non-empty", McpResource.class.getSimpleName());
673                                        valid = false;
674                                } else if (!resourceNames.add(resource.name())) {
675                                        error(method, "Soklet: Duplicate MCP resource name '%s'", resource.name());
676                                        valid = false;
677                                }
678
679                                if (resource.mimeType().isBlank()) {
680                                        error(method, "Soklet: @%s mimeType must be non-empty", McpResource.class.getSimpleName());
681                                        valid = false;
682                                }
683                        }
684
685                        if (method.getAnnotation(McpListResources.class) != null) {
686                                if (!validateMcpAnnotatedMethod(method, McpListResources.class.getSimpleName()))
687                                        valid = false;
688
689                                resourceListMethodCount++;
690                        }
691                }
692
693                if (resourceListMethodCount > 1) {
694                        error(endpointType, "Soklet: At most one @%s method may be declared on an MCP endpoint class.",
695                                        McpListResources.class.getSimpleName());
696                        valid = false;
697                }
698
699                if (valid)
700                        collectedMcpEndpoints.add(new McpEndpointDeclaration(elements.getBinaryName(endpointType).toString()));
701        }
702
703        private void validateMcpAnnotatedMethodsBelongToEndpoint(RoundEnvironment roundEnv,
704                                                                                                                                                                                                                 Class<? extends Annotation> annotationType) {
705                TypeElement annotationElement = elements.getTypeElement(annotationType.getCanonicalName());
706
707                if (annotationElement == null)
708                        return;
709
710                for (Element element : roundEnv.getElementsAnnotatedWith(annotationElement)) {
711                        if (element.getKind() != ElementKind.METHOD) {
712                                error(element, "Soklet: @%s can only be applied to methods.", annotationType.getSimpleName());
713                                continue;
714                        }
715
716                        Element enclosingElement = element.getEnclosingElement();
717                        if (!(enclosingElement instanceof TypeElement enclosingType)
718                                        || enclosingType.getAnnotation(McpServerEndpoint.class) == null) {
719                                error(element, "Soklet: Methods annotated with @%s must be declared on a class annotated with @%s.",
720                                                annotationType.getSimpleName(), McpServerEndpoint.class.getSimpleName());
721                        }
722                }
723        }
724
725        private boolean validateMcpAnnotatedMethod(ExecutableElement method, String annotationSimpleName) {
726                boolean valid = true;
727
728                if (!method.getModifiers().contains(Modifier.PUBLIC)) {
729                        error(method, "Soklet: Methods annotated with @%s must be public.", annotationSimpleName);
730                        valid = false;
731                }
732
733                if (method.getModifiers().contains(Modifier.STATIC)) {
734                        error(method, "Soklet: Methods annotated with @%s must not be static.", annotationSimpleName);
735                        valid = false;
736                }
737
738                return valid;
739        }
740
741        // --- Existing utilities ----------------------------------------------------
742
743        private static String normalizePath(String p) {
744                if (p == null || p.isEmpty()) return "/";
745                if (p.charAt(0) != '/') return "/" + p;
746                return p;
747        }
748
749        private static Class<? extends Annotation> findRepeatableContainer(Class<? extends Annotation> base) {
750                Repeatable repeatable = base.getAnnotation(Repeatable.class);
751                return (repeatable == null) ? null : repeatable.value();
752        }
753
754        private String jvmTypeName(TypeMirror t) {
755                switch (t.getKind()) {
756                        case BOOLEAN:
757                                return "boolean";
758                        case BYTE:
759                                return "byte";
760                        case SHORT:
761                                return "short";
762                        case CHAR:
763                                return "char";
764                        case INT:
765                                return "int";
766                        case LONG:
767                                return "long";
768                        case FLOAT:
769                                return "float";
770                        case DOUBLE:
771                                return "double";
772                        case VOID:
773                                return "void";
774                        case ARRAY:
775                                return "[" + jvmTypeDescriptor(((javax.lang.model.type.ArrayType) t).getComponentType());
776                        case DECLARED:
777                        default:
778                                TypeMirror erasure = processingEnv.getTypeUtils().erasure(t);
779                                Element el = processingEnv.getTypeUtils().asElement(erasure);
780                                if (el instanceof TypeElement te) {
781                                        return processingEnv.getElementUtils().getBinaryName(te).toString();
782                                }
783                                return erasure.toString();
784                }
785        }
786
787        private String jvmTypeDescriptor(TypeMirror t) {
788                switch (t.getKind()) {
789                        case BOOLEAN:
790                                return "Z";
791                        case BYTE:
792                                return "B";
793                        case SHORT:
794                                return "S";
795                        case CHAR:
796                                return "C";
797                        case INT:
798                                return "I";
799                        case LONG:
800                                return "J";
801                        case FLOAT:
802                                return "F";
803                        case DOUBLE:
804                                return "D";
805                        case ARRAY:
806                                return "[" + jvmTypeDescriptor(((javax.lang.model.type.ArrayType) t).getComponentType());
807                        case DECLARED:
808                        default:
809                                TypeMirror erasure = processingEnv.getTypeUtils().erasure(t);
810                                Element el = processingEnv.getTypeUtils().asElement(erasure);
811                                if (el instanceof TypeElement te) {
812                                        String bin = processingEnv.getElementUtils().getBinaryName(te).toString();
813                                        return "L" + bin + ";";
814                                }
815                                return "Ljava/lang/Object;";
816                }
817        }
818
819        // ---- SSE return-type validation ------------------------------------------
820
821        private void enforceSseReturnTypes(RoundEnvironment roundEnv) {
822                enforceAnnotatedReturnTypes(roundEnv, SseEventSource.class, sseHandshakeResultType, "SseHandshakeResult");
823        }
824
825        private void enforceMcpReturnTypes(RoundEnvironment roundEnv) {
826                enforceAnnotatedReturnTypes(roundEnv, McpTool.class, mcpToolResultType, "McpToolResult");
827                enforceAnnotatedReturnTypes(roundEnv, McpPrompt.class, mcpPromptResultType, "McpPromptResult");
828                enforceAnnotatedReturnTypes(roundEnv, McpResource.class, mcpResourceContentsType, "McpResourceContents");
829                enforceAnnotatedReturnTypes(roundEnv, McpListResources.class, mcpListResourcesResultType, "McpListResourcesResult");
830        }
831
832        private void enforceAnnotatedReturnTypes(RoundEnvironment roundEnv,
833                                                                                                                                                                         Class<? extends Annotation> annotationType,
834                                                                                                                                                                         TypeMirror expectedReturnType,
835                                                                                                                                                                         String expectedReturnTypeName) {
836                if (expectedReturnType == null)
837                        return;
838
839                TypeElement annotationElement = elements.getTypeElement(annotationType.getCanonicalName());
840                if (annotationElement == null)
841                        return;
842
843                for (Element element : roundEnv.getElementsAnnotatedWith(annotationElement)) {
844                        if (element.getKind() != ElementKind.METHOD) {
845                                error(element, "@%s can only be applied to methods.", annotationType.getSimpleName());
846                                continue;
847                        }
848
849                        ExecutableElement method = (ExecutableElement) element;
850                        TypeMirror returnType = method.getReturnType();
851                        boolean assignable = types.isAssignable(returnType, expectedReturnType);
852
853                        if (!assignable) {
854                                error(element,
855                                                "Soklet: Methods annotated with @%s must specify a return type of %s (found: %s).",
856                                                annotationType.getSimpleName(), expectedReturnTypeName, prettyType(returnType));
857                        }
858                }
859        }
860
861        private static String prettyType(TypeMirror t) {
862                return (t == null ? "null" : t.toString());
863        }
864
865        // ---- Index read/merge/write ----------------------------------------------
866
867        private void detectResourceMethodAmbiguity(@NonNull Element element,
868                                                                                                                                                                                 @NonNull ResourceMethodDeclaration declaration) {
869                for (ResourceMethodDeclaration existing : dedupeAndOrder(collected)) {
870                        if (resourceMethodDeclarationsAmbiguous(existing, declaration)) {
871                                resourceMethodAmbiguityDetected = true;
872                                error(element, "Soklet: Ambiguous resource method declarations detected. %s overlaps %s",
873                                                describeResourceMethodDeclaration(declaration), describeResourceMethodDeclaration(existing));
874                        }
875                }
876        }
877
878        private static boolean resourceMethodDeclarationsAmbiguous(@NonNull ResourceMethodDeclaration first,
879                                                                                                                                                                                                                                                        @NonNull ResourceMethodDeclaration second) {
880                if (generateKey(first).equals(generateKey(second)))
881                        return false;
882
883                ResourcePathDeclaration firstPath = ResourcePathDeclaration.fromPath(first.path());
884                ResourcePathDeclaration secondPath = ResourcePathDeclaration.fromPath(second.path());
885                ResourceMethodSpecificityKey firstKey = specificityKey(first, firstPath);
886                ResourceMethodSpecificityKey secondKey = specificityKey(second, secondPath);
887
888                return firstKey.equals(secondKey) && resourcePathDeclarationsOverlap(firstPath, secondPath);
889        }
890
891        @NonNull
892        private static ResourceMethodSpecificityKey specificityKey(@NonNull ResourceMethodDeclaration declaration,
893                                                                                                                                                                                                                                                 @NonNull ResourcePathDeclaration resourcePathDeclaration) {
894                return new ResourceMethodSpecificityKey(
895                                declaration.httpMethod(),
896                                declaration.sseEventSource(),
897                                resourcePathDeclaration.getVarargsComponent().isPresent(),
898                                placeholderCount(resourcePathDeclaration),
899                                literalCount(resourcePathDeclaration));
900        }
901
902        @NonNull
903        private static String describeResourceMethodDeclaration(@NonNull ResourceMethodDeclaration declaration) {
904                return String.format("%s %s %s -> %s#%s(%s)",
905                                declaration.sseEventSource() ? "SSE" : "HTTP",
906                                declaration.httpMethod().name(),
907                                declaration.path(),
908                                declaration.className(),
909                                declaration.methodName(),
910                                String.join(", ", declaration.parameterTypes()));
911        }
912
913        private static long placeholderCount(@NonNull ResourcePathDeclaration declaration) {
914                return declaration.getComponents().stream()
915                                .filter(component -> component.getType() == ResourcePathDeclaration.ComponentType.PLACEHOLDER)
916                                .count();
917        }
918
919        private static long literalCount(@NonNull ResourcePathDeclaration declaration) {
920                return declaration.getComponents().stream()
921                                .filter(component -> component.getType() == ResourcePathDeclaration.ComponentType.LITERAL)
922                                .count();
923        }
924
925        private static boolean resourcePathDeclarationsOverlap(@NonNull ResourcePathDeclaration first,
926                                                                                                                                                                                                                                 @NonNull ResourcePathDeclaration second) {
927                List<ResourcePathDeclaration.Component> firstComponents = first.getComponents();
928                List<ResourcePathDeclaration.Component> secondComponents = second.getComponents();
929
930                boolean firstHasVarargs = first.getVarargsComponent().isPresent();
931                boolean secondHasVarargs = second.getVarargsComponent().isPresent();
932
933                int firstPrefixLength = firstComponents.size() - (firstHasVarargs ? 1 : 0);
934                int secondPrefixLength = secondComponents.size() - (secondHasVarargs ? 1 : 0);
935
936                if (!firstHasVarargs && !secondHasVarargs) {
937                        if (firstComponents.size() != secondComponents.size())
938                                return false;
939
940                        for (int i = 0; i < firstComponents.size(); i++)
941                                if (!componentsCompatible(firstComponents.get(i), secondComponents.get(i)))
942                                        return false;
943
944                        return true;
945                }
946
947                if (firstHasVarargs && !secondHasVarargs) {
948                        if (secondComponents.size() < firstPrefixLength)
949                                return false;
950
951                        for (int i = 0; i < firstPrefixLength; i++)
952                                if (!componentsCompatible(firstComponents.get(i), secondComponents.get(i)))
953                                        return false;
954
955                        return true;
956                }
957
958                if (!firstHasVarargs) {
959                        if (firstComponents.size() < secondPrefixLength)
960                                return false;
961
962                        for (int i = 0; i < secondPrefixLength; i++)
963                                if (!componentsCompatible(firstComponents.get(i), secondComponents.get(i)))
964                                        return false;
965
966                        return true;
967                }
968
969                int minPrefixLength = Math.min(firstPrefixLength, secondPrefixLength);
970
971                for (int i = 0; i < minPrefixLength; i++)
972                        if (!componentsCompatible(firstComponents.get(i), secondComponents.get(i)))
973                                return false;
974
975                return true;
976        }
977
978        private static boolean componentsCompatible(ResourcePathDeclaration.@NonNull Component first,
979                                                                                                                                                                                        ResourcePathDeclaration.@NonNull Component second) {
980                if (first.getType() == ResourcePathDeclaration.ComponentType.LITERAL
981                                && second.getType() == ResourcePathDeclaration.ComponentType.LITERAL)
982                        return first.getValue().equals(second.getValue());
983
984                return true;
985        }
986
987        private void mergeAndWriteIndex(List<ResourceMethodDeclaration> newlyCollected,
988                                                                                                                                        Set<String> touchedTopLevelBinaries) {
989
990                Path classOutputRoot = findClassOutputRoot();
991                Path classOutputIndexPath = (classOutputRoot == null ? null : classOutputRoot.resolve(RESOURCE_METHOD_LOOKUP_TABLE_PATH));
992
993                Path sideCarIndexPath = (cacheMode == CacheMode.NONE ? null : sideCarIndexPath(classOutputRoot));
994                Path persistentIndexPath = (cacheMode == CacheMode.PERSISTENT ? persistentIndexPath(classOutputRoot) : null);
995
996                debug("SokletProcessor: cacheMode=%s", cacheMode);
997                debug("SokletProcessor: classOutputRoot=%s", classOutputRoot);
998                debug("SokletProcessor: classOutputIndexPath=%s", classOutputIndexPath);
999                debug("SokletProcessor: sidecarIndexPath=%s", sideCarIndexPath);
1000                debug("SokletProcessor: persistentIndexPath=%s", persistentIndexPath);
1001                debug("SokletProcessor: touchedTopLevels=%s", touchedTopLevelBinaries);
1002
1003                // Always merge from ALL enabled sources. Never "fallback only if empty".
1004                Map<String, ResourceMethodDeclaration> merged = new LinkedHashMap<>();
1005
1006                // Oldest/most durable first
1007                if (persistentIndexPath != null) readIndexFromPath(persistentIndexPath, merged);
1008                if (sideCarIndexPath != null) readIndexFromPath(sideCarIndexPath, merged);
1009
1010                // Then current output dir (direct file access, if possible)
1011                if (classOutputIndexPath != null) readIndexFromPath(classOutputIndexPath, merged);
1012
1013                // Then via filer (often works even if direct file paths don't)
1014                readIndexFromLocation(StandardLocation.CLASS_OUTPUT, merged);
1015
1016                debug("SokletProcessor: mergedExistingIndexSize=%d", merged.size());
1017
1018                // Remove stale entries for classes being recompiled now (top-level + nested)
1019                removeTouchedEntries(merged, touchedTopLevelBinaries);
1020                debug("SokletProcessor: afterRemovingTouched=%d", merged.size());
1021
1022                // Add new entries
1023                for (ResourceMethodDeclaration r : dedupeAndOrder(newlyCollected)) {
1024                        merged.put(generateKey(r), r);
1025                }
1026
1027                // Optional prune by classfile existence (NOT IDE-safe by default)
1028                if (pruneDeletedEnabled && classOutputRoot != null) {
1029                        merged.values().removeIf(r -> !classFileExistsInOutputRoot(classOutputRoot, r.className()));
1030                        debug("SokletProcessor: afterPruneDeleted=%d", merged.size());
1031                }
1032
1033                List<ResourceMethodDeclaration> toWrite = new ArrayList<>(merged.values());
1034                toWrite.sort(Comparator
1035                                .comparing((ResourceMethodDeclaration r) -> r.httpMethod().name())
1036                                .thenComparing(ResourceMethodDeclaration::path)
1037                                .thenComparing(ResourceMethodDeclaration::className)
1038                                .thenComparing(ResourceMethodDeclaration::methodName));
1039
1040                // Write CLASS_OUTPUT index (the real output)
1041                writeRoutesIndexResource(toWrite, classOutputIndexPath, touchedTopLevelBinaries, newlyCollected);
1042
1043                // Write caches (best-effort)
1044                if (sideCarIndexPath != null) writeIndexFileAtomically(sideCarIndexPath, toWrite);
1045                if (persistentIndexPath != null) writeIndexFileAtomically(persistentIndexPath, toWrite);
1046
1047                debug("SokletProcessor: wroteIndexSize=%d", toWrite.size());
1048        }
1049
1050        private void mergeAndWriteMcpIndex(List<McpEndpointDeclaration> newlyCollected,
1051                                                                                                                                                 Set<String> touchedTopLevelBinaries) {
1052                Path classOutputRoot = findClassOutputRoot();
1053                Path classOutputIndexPath = (classOutputRoot == null ? null : classOutputRoot.resolve(MCP_ENDPOINT_LOOKUP_TABLE_PATH));
1054                Path sideCarIndexPath = (cacheMode == CacheMode.NONE ? null : sideCarMcpIndexPath(classOutputRoot));
1055                Path persistentIndexPath = (cacheMode == CacheMode.PERSISTENT ? persistentMcpIndexPath(classOutputRoot) : null);
1056
1057                Map<String, McpEndpointDeclaration> merged = new LinkedHashMap<>();
1058
1059                if (persistentIndexPath != null) readMcpIndexFromPath(persistentIndexPath, merged);
1060                if (sideCarIndexPath != null) readMcpIndexFromPath(sideCarIndexPath, merged);
1061                if (classOutputIndexPath != null) readMcpIndexFromPath(classOutputIndexPath, merged);
1062                readMcpIndexFromLocation(StandardLocation.CLASS_OUTPUT, merged);
1063
1064                removeTouchedMcpEntries(merged, touchedTopLevelBinaries);
1065
1066                for (McpEndpointDeclaration endpointDeclaration : dedupeAndOrderMcpEndpoints(newlyCollected))
1067                        merged.put(generateMcpEndpointKey(endpointDeclaration), endpointDeclaration);
1068
1069                if (pruneDeletedEnabled && classOutputRoot != null)
1070                        merged.values().removeIf(endpointDeclaration -> !classFileExistsInOutputRoot(classOutputRoot, endpointDeclaration.className()));
1071
1072                List<McpEndpointDeclaration> toWrite = dedupeAndOrderMcpEndpoints(new ArrayList<>(merged.values()));
1073
1074                writeMcpIndexResource(toWrite, classOutputIndexPath, touchedTopLevelBinaries, newlyCollected);
1075
1076                if (sideCarIndexPath != null) writeMcpIndexFileAtomically(sideCarIndexPath, toWrite);
1077                if (persistentIndexPath != null) writeMcpIndexFileAtomically(persistentIndexPath, toWrite);
1078        }
1079
1080        private void removeTouchedEntries(Map<String, ResourceMethodDeclaration> merged,
1081                                                                                                                                                Set<String> touchedTopLevelBinaries) {
1082                if (touchedTopLevelBinaries == null || touchedTopLevelBinaries.isEmpty()) return;
1083
1084                merged.values().removeIf(r -> {
1085                        String ownerBin = r.className();
1086                        for (String top : touchedTopLevelBinaries) {
1087                                if (ownerBin.equals(top) || ownerBin.startsWith(top + "$")) return true;
1088                        }
1089                        return false;
1090                });
1091        }
1092
1093        private void removeTouchedMcpEntries(Map<String, McpEndpointDeclaration> merged,
1094                                                                                                                                                         Set<String> touchedTopLevelBinaries) {
1095                if (touchedTopLevelBinaries == null || touchedTopLevelBinaries.isEmpty()) return;
1096
1097                merged.values().removeIf(endpointDeclaration -> {
1098                        String ownerBin = endpointDeclaration.className();
1099                        for (String top : touchedTopLevelBinaries) {
1100                                if (ownerBin.equals(top) || ownerBin.startsWith(top + "$")) return true;
1101                        }
1102                        return false;
1103                });
1104        }
1105
1106        private boolean readIndexFromLocation(StandardLocation location, Map<String, ResourceMethodDeclaration> out) {
1107                try {
1108                        FileObject fo = filer.getResource(location, "", RESOURCE_METHOD_LOOKUP_TABLE_PATH);
1109                        try (BufferedReader reader = new BufferedReader(new InputStreamReader(fo.openInputStream(), StandardCharsets.UTF_8))) {
1110                                readIndexFromReader(reader, out);
1111                        }
1112                        return true;
1113                } catch (IOException ignored) {
1114                        return false;
1115                }
1116        }
1117
1118        private boolean readMcpIndexFromLocation(StandardLocation location, Map<String, McpEndpointDeclaration> out) {
1119                try {
1120                        FileObject fo = filer.getResource(location, "", MCP_ENDPOINT_LOOKUP_TABLE_PATH);
1121                        try (BufferedReader reader = new BufferedReader(new InputStreamReader(fo.openInputStream(), StandardCharsets.UTF_8))) {
1122                                readMcpIndexFromReader(reader, out);
1123                        }
1124                        return true;
1125                } catch (IOException ignored) {
1126                        return false;
1127                }
1128        }
1129
1130        private boolean readIndexFromPath(Path path, Map<String, ResourceMethodDeclaration> out) {
1131                if (path == null || !Files.isRegularFile(path)) return false;
1132                try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
1133                        readIndexFromReader(reader, out);
1134                        return true;
1135                } catch (IOException ignored) {
1136                        return false;
1137                }
1138        }
1139
1140        private boolean readMcpIndexFromPath(Path path, Map<String, McpEndpointDeclaration> out) {
1141                if (path == null || !Files.isRegularFile(path)) return false;
1142                try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
1143                        readMcpIndexFromReader(reader, out);
1144                        return true;
1145                } catch (IOException ignored) {
1146                        return false;
1147                }
1148        }
1149
1150        private void readIndexFromReader(BufferedReader reader, Map<String, ResourceMethodDeclaration> out) throws IOException {
1151                String line;
1152                while ((line = reader.readLine()) != null) {
1153                        line = line.trim();
1154                        if (line.isEmpty()) continue;
1155                        ResourceMethodDeclaration r = parseIndexLine(line);
1156                        if (r != null) out.put(generateKey(r), r);
1157                }
1158        }
1159
1160        private void readMcpIndexFromReader(BufferedReader reader, Map<String, McpEndpointDeclaration> out) throws IOException {
1161                String line;
1162                while ((line = reader.readLine()) != null) {
1163                        line = line.trim();
1164                        if (line.isEmpty()) continue;
1165                        McpEndpointDeclaration endpointDeclaration = parseMcpIndexLine(line);
1166                        if (endpointDeclaration != null) out.put(generateMcpEndpointKey(endpointDeclaration), endpointDeclaration);
1167                }
1168        }
1169
1170        private Path findClassOutputRoot() {
1171                // Try to read an existing marker file
1172                try {
1173                        FileObject fo = filer.getResource(StandardLocation.CLASS_OUTPUT, "", OUTPUT_ROOT_MARKER_PATH);
1174                        Path root = outputRootFromUri(fo.toUri(), OUTPUT_ROOT_MARKER_PATH);
1175                        if (root != null) return root;
1176                } catch (IOException ignored) {
1177                        // The marker may not exist yet; create it below if possible.
1178                }
1179
1180                // Create marker to discover root
1181                try {
1182                        FileObject fo = filer.createResource(StandardLocation.CLASS_OUTPUT, "", OUTPUT_ROOT_MARKER_PATH);
1183                        try (Writer w = fo.openWriter()) {
1184                                w.write("");
1185                        }
1186                        return outputRootFromUri(fo.toUri(), OUTPUT_ROOT_MARKER_PATH);
1187                } catch (IOException ignored) {
1188                        return null;
1189                }
1190        }
1191
1192        private Path sideCarIndexPath(Path classOutputRoot) {
1193                if (classOutputRoot == null) return null;
1194                Path parent = classOutputRoot.getParent();
1195                if (parent == null) return null;
1196                Path outputRootFileName = classOutputRoot.getFileName();
1197                if (outputRootFileName == null) return null;
1198                String outputRootName = outputRootFileName.toString();
1199                return parent.resolve(SIDE_CAR_DIR_NAME).resolve(outputRootName).resolve(SIDE_CAR_INDEX_FILENAME);
1200        }
1201
1202        private Path persistentIndexPath(Path classOutputRoot) {
1203                if (classOutputRoot == null) return null;
1204                Path cacheRoot = persistentCacheRoot();
1205                if (cacheRoot == null) return null;
1206
1207                String key = hashPath(classOutputRoot.toAbsolutePath().normalize().toString());
1208                return cacheRoot.resolve(PERSISTENT_CACHE_INDEX_DIR).resolve(key).resolve(SIDE_CAR_INDEX_FILENAME);
1209        }
1210
1211        private Path sideCarMcpIndexPath(Path classOutputRoot) {
1212                if (classOutputRoot == null) return null;
1213                Path parent = classOutputRoot.getParent();
1214                if (parent == null) return null;
1215                Path outputRootFileName = classOutputRoot.getFileName();
1216                if (outputRootFileName == null) return null;
1217                String outputRootName = outputRootFileName.toString();
1218                return parent.resolve(SIDE_CAR_DIR_NAME).resolve(outputRootName).resolve(MCP_SIDE_CAR_INDEX_FILENAME);
1219        }
1220
1221        private Path persistentMcpIndexPath(Path classOutputRoot) {
1222                if (classOutputRoot == null) return null;
1223                Path cacheRoot = persistentCacheRoot();
1224                if (cacheRoot == null) return null;
1225
1226                String key = hashPath(classOutputRoot.toAbsolutePath().normalize().toString());
1227                return cacheRoot.resolve(MCP_PERSISTENT_CACHE_INDEX_DIR).resolve(key).resolve(MCP_SIDE_CAR_INDEX_FILENAME);
1228        }
1229
1230        /**
1231         * Persistent caching is only enabled when soklet.cacheDir is explicitly set.
1232         * This avoids writing project-root ".soklet" directories by default.
1233         */
1234        private Path persistentCacheRoot() {
1235                String override = processingEnv.getOptions().get(PROCESSOR_OPTION_CACHE_DIR);
1236                if (override == null || override.isBlank()) return null;
1237                try {
1238                        return Paths.get(override);
1239                } catch (RuntimeException ignored) {
1240                        return null;
1241                }
1242        }
1243
1244        private boolean classFileExistsInOutputRoot(Path root, String binaryName) {
1245                if (root == null) return true;
1246                Path classFile = root.resolve(binaryName.replace('.', '/') + ".class");
1247                return Files.isRegularFile(classFile);
1248        }
1249
1250        private Path outputRootFromUri(URI uri, String pathSuffix) {
1251                if (uri == null || !"file".equalsIgnoreCase(uri.getScheme())) return null;
1252                Path file = Paths.get(uri);
1253                int segments = countPathSegments(pathSuffix);
1254                Path root = file;
1255                for (int i = 0; i < segments; i++) {
1256                        root = root.getParent();
1257                        if (root == null) return null;
1258                }
1259                return root;
1260        }
1261
1262        private ResourceMethodDeclaration parseIndexLine(String line) {
1263                try {
1264                        String[] parts = line.split("\\|", -1);
1265                        if (parts.length < 6) return null;
1266
1267                        HttpMethod httpMethod = HttpMethod.valueOf(parts[0]);
1268                        Base64.Decoder dec = Base64.getDecoder();
1269
1270                        String path = new String(dec.decode(parts[1]), StandardCharsets.UTF_8);
1271                        String className = new String(dec.decode(parts[2]), StandardCharsets.UTF_8);
1272                        String methodName = new String(dec.decode(parts[3]), StandardCharsets.UTF_8);
1273                        String paramsJoined = new String(dec.decode(parts[4]), StandardCharsets.UTF_8);
1274                        boolean sse = Boolean.parseBoolean(parts[5]);
1275
1276                        String[] paramTypes;
1277                        if (paramsJoined.isEmpty()) {
1278                                paramTypes = new String[0];
1279                        } else {
1280                                List<String> tmp = Arrays.stream(paramsJoined.split(";"))
1281                                                .filter(s -> !s.isEmpty())
1282                                                .collect(Collectors.toList());
1283                                paramTypes = tmp.toArray(String[]::new);
1284                        }
1285
1286                        return new ResourceMethodDeclaration(httpMethod, path, className, methodName, paramTypes, sse);
1287                } catch (Throwable t) {
1288                        return null;
1289                }
1290        }
1291
1292        private McpEndpointDeclaration parseMcpIndexLine(String line) {
1293                try {
1294                        String className = new String(Base64.getDecoder().decode(line), StandardCharsets.UTF_8);
1295                        return new McpEndpointDeclaration(className);
1296                } catch (Throwable t) {
1297                        return null;
1298                }
1299        }
1300
1301        /**
1302         * Writes the merged index to CLASS_OUTPUT.
1303         * Uses originating elements (best effort) so incremental build tools can track dependencies.
1304         *
1305         * <p>Fallback strategy if createResource fails:
1306         * <ol>
1307         *   <li>Try opening a writer on filer.getResource(...)</li>
1308         *   <li>Try direct filesystem write if classOutputIndexPath is available</li>
1309         * </ol>
1310         */
1311        private void writeRoutesIndexResource(List<ResourceMethodDeclaration> routes,
1312                                                                                                                                                                Path classOutputIndexPath,
1313                                                                                                                                                                Set<String> touchedTopLevelBinaries,
1314                                                                                                                                                                List<ResourceMethodDeclaration> newlyCollected) {
1315                Element[] origins = computeOriginatingElements(touchedTopLevelBinaries, newlyCollected);
1316
1317                try {
1318                        FileObject fo = filer.createResource(StandardLocation.CLASS_OUTPUT, "", RESOURCE_METHOD_LOOKUP_TABLE_PATH, origins);
1319                        try (Writer w = fo.openWriter()) {
1320                                writeIndexToWriter(w, routes);
1321                        }
1322                        return;
1323                } catch (FilerException exists) {
1324                        // Try writing via getResource/openWriter
1325                        try {
1326                                FileObject fo = filer.getResource(StandardLocation.CLASS_OUTPUT, "", RESOURCE_METHOD_LOOKUP_TABLE_PATH);
1327                                try (Writer w = fo.openWriter()) {
1328                                        writeIndexToWriter(w, routes);
1329                                }
1330                                return;
1331                        } catch (IOException ignored) {
1332                                // Fall through to direct path write if available
1333                        }
1334                } catch (IOException e) {
1335                        // Fall through to direct path write if available
1336                        debug("SokletProcessor: filer.createResource/openWriter failed (%s); attempting direct write.", e);
1337                }
1338
1339                // Direct path write (best effort)
1340                if (classOutputIndexPath != null) {
1341                        try {
1342                                writeIndexFileAtomicallyOrThrow(classOutputIndexPath, routes);
1343                                return;
1344                        } catch (IOException e) {
1345                                throw new UncheckedIOException("Failed to write " + RESOURCE_METHOD_LOOKUP_TABLE_PATH, e);
1346                        }
1347                }
1348
1349                throw new UncheckedIOException("Failed to write " + RESOURCE_METHOD_LOOKUP_TABLE_PATH, new IOException("No writable CLASS_OUTPUT path available"));
1350        }
1351
1352        private void writeMcpIndexResource(List<McpEndpointDeclaration> endpoints,
1353                                                                                                                                                 Path classOutputIndexPath,
1354                                                                                                                                                 Set<String> touchedTopLevelBinaries,
1355                                                                                                                                                 List<McpEndpointDeclaration> newlyCollected) {
1356                Element[] origins = computeMcpOriginatingElements(touchedTopLevelBinaries, newlyCollected);
1357
1358                try {
1359                        FileObject fo = filer.createResource(StandardLocation.CLASS_OUTPUT, "", MCP_ENDPOINT_LOOKUP_TABLE_PATH, origins);
1360                        try (Writer w = fo.openWriter()) {
1361                                writeMcpIndexToWriter(w, endpoints);
1362                        }
1363                        return;
1364                } catch (FilerException exists) {
1365                        try {
1366                                FileObject fo = filer.getResource(StandardLocation.CLASS_OUTPUT, "", MCP_ENDPOINT_LOOKUP_TABLE_PATH);
1367                                try (Writer w = fo.openWriter()) {
1368                                        writeMcpIndexToWriter(w, endpoints);
1369                                }
1370                                return;
1371                        } catch (IOException ignored) {
1372                                // Fall through to direct path write if available
1373                        }
1374                } catch (IOException e) {
1375                        debug("SokletProcessor: filer.createResource/openWriter for MCP index failed (%s); attempting direct write.", e);
1376                }
1377
1378                if (classOutputIndexPath != null) {
1379                        try {
1380                                writeMcpIndexFileAtomicallyOrThrow(classOutputIndexPath, endpoints);
1381                                return;
1382                        } catch (IOException e) {
1383                                throw new UncheckedIOException("Failed to write " + MCP_ENDPOINT_LOOKUP_TABLE_PATH, e);
1384                        }
1385                }
1386
1387                throw new UncheckedIOException("Failed to write " + MCP_ENDPOINT_LOOKUP_TABLE_PATH, new IOException("No writable CLASS_OUTPUT path available"));
1388        }
1389
1390        private Element[] computeOriginatingElements(Set<String> touchedTopLevelBinaries,
1391                                                                                                                                                                                         List<ResourceMethodDeclaration> newlyCollected) {
1392                Set<Element> origins = new LinkedHashSet<>();
1393
1394                // Always include touched top-level types (these are definitely in this compilation)
1395                if (touchedTopLevelBinaries != null) {
1396                        for (String top : touchedTopLevelBinaries) {
1397                                TypeElement te = elements.getTypeElement(top);
1398                                if (te != null) origins.add(te);
1399                        }
1400                }
1401
1402                // Also include owners of newly collected routes (top-level if possible)
1403                if (newlyCollected != null) {
1404                        for (ResourceMethodDeclaration r : newlyCollected) {
1405                                String bin = r.className();
1406                                int dollar = bin.indexOf('$');
1407                                String top = (dollar >= 0) ? bin.substring(0, dollar) : bin;
1408
1409                                TypeElement te = elements.getTypeElement(top);
1410                                if (te != null) origins.add(te);
1411                        }
1412                }
1413
1414                return origins.toArray(new Element[0]);
1415        }
1416
1417        private Element[] computeMcpOriginatingElements(Set<String> touchedTopLevelBinaries,
1418                                                                                                                                                                                                        List<McpEndpointDeclaration> newlyCollected) {
1419                Set<Element> origins = new LinkedHashSet<>();
1420
1421                if (touchedTopLevelBinaries != null) {
1422                        for (String top : touchedTopLevelBinaries) {
1423                                TypeElement te = elements.getTypeElement(top);
1424                                if (te != null) origins.add(te);
1425                        }
1426                }
1427
1428                if (newlyCollected != null) {
1429                        for (McpEndpointDeclaration endpointDeclaration : newlyCollected) {
1430                                String bin = endpointDeclaration.className();
1431                                int dollar = bin.indexOf('$');
1432                                String top = (dollar >= 0) ? bin.substring(0, dollar) : bin;
1433
1434                                TypeElement te = elements.getTypeElement(top);
1435                                if (te != null) origins.add(te);
1436                        }
1437                }
1438
1439                return origins.toArray(new Element[0]);
1440        }
1441
1442        private void writeIndexToWriter(Writer w, List<ResourceMethodDeclaration> routes) throws IOException {
1443                Base64.Encoder b64 = Base64.getEncoder();
1444                for (ResourceMethodDeclaration r : routes) {
1445                        String params = String.join(";", r.parameterTypes());
1446                        String line = String.join("|",
1447                                        r.httpMethod().name(),
1448                                        b64encode(b64, r.path()),
1449                                        b64encode(b64, r.className()),
1450                                        b64encode(b64, r.methodName()),
1451                                        b64encode(b64, params),
1452                                        Boolean.toString(r.sseEventSource())
1453                        );
1454                        w.write(line);
1455                        w.write('\n');
1456                }
1457        }
1458
1459        private void writeMcpIndexToWriter(Writer w, List<McpEndpointDeclaration> endpoints) throws IOException {
1460                Base64.Encoder b64 = Base64.getEncoder();
1461                for (McpEndpointDeclaration endpointDeclaration : endpoints) {
1462                        w.write(b64encode(b64, endpointDeclaration.className()));
1463                        w.write('\n');
1464                }
1465        }
1466
1467        /**
1468         * Best-effort atomic write. Failures are logged (if debug enabled) and ignored.
1469         */
1470        private void writeIndexFileAtomically(Path target, List<ResourceMethodDeclaration> routes) {
1471                if (target == null) return;
1472                try {
1473                        writeIndexFileAtomicallyOrThrow(target, routes);
1474                } catch (IOException e) {
1475                        debug("SokletProcessor: failed to write cache index %s (%s)", target, e);
1476                }
1477        }
1478
1479        private void writeMcpIndexFileAtomically(Path target, List<McpEndpointDeclaration> endpoints) {
1480                if (target == null) return;
1481                try {
1482                        writeMcpIndexFileAtomicallyOrThrow(target, endpoints);
1483                } catch (IOException e) {
1484                        debug("SokletProcessor: failed to write MCP cache index %s (%s)", target, e);
1485                }
1486        }
1487
1488        private void writeIndexFileAtomicallyOrThrow(Path target, List<ResourceMethodDeclaration> routes) throws IOException {
1489                Path parent = target.getParent();
1490                if (parent != null) Files.createDirectories(parent);
1491
1492                // temp file in same dir so move is atomic on most filesystems
1493                Path targetFileName = target.getFileName();
1494                if (targetFileName == null)
1495                        throw new IOException("Unable to determine filename for " + target);
1496
1497                Path tmp = Files.createTempFile(parent == null ? Path.of(".") : parent, targetFileName.toString(), ".tmp");
1498                try (Writer w = Files.newBufferedWriter(tmp, StandardCharsets.UTF_8)) {
1499                        writeIndexToWriter(w, routes);
1500                }
1501
1502                try {
1503                        Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
1504                } catch (AtomicMoveNotSupportedException e) {
1505                        Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING);
1506                }
1507        }
1508
1509        private void writeMcpIndexFileAtomicallyOrThrow(Path target, List<McpEndpointDeclaration> endpoints) throws IOException {
1510                Path parent = target.getParent();
1511                if (parent != null) Files.createDirectories(parent);
1512
1513                Path targetFileName = target.getFileName();
1514                if (targetFileName == null)
1515                        throw new IOException("Unable to determine filename for " + target);
1516
1517                Path tmp = Files.createTempFile(parent == null ? Path.of(".") : parent, targetFileName.toString(), ".tmp");
1518                try (Writer w = Files.newBufferedWriter(tmp, StandardCharsets.UTF_8)) {
1519                        writeMcpIndexToWriter(w, endpoints);
1520                }
1521
1522                try {
1523                        Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
1524                } catch (AtomicMoveNotSupportedException e) {
1525                        Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING);
1526                }
1527        }
1528
1529        private static String b64encode(Base64.Encoder enc, String s) {
1530                byte[] bytes = (s == null ? new byte[0] : s.getBytes(StandardCharsets.UTF_8));
1531                return enc.encodeToString(bytes);
1532        }
1533
1534        // ---- Messaging ------------------------------------------------------------
1535
1536        @FormatMethod
1537        private void error(Element e, String fmt, Object... args) {
1538                messager.printMessage(Diagnostic.Kind.ERROR, String.format(fmt, args), e);
1539        }
1540
1541        @FormatMethod
1542        private void debug(String fmt, Object... args) {
1543                if (!debugEnabled) return;
1544                messager.printMessage(Diagnostic.Kind.NOTE, String.format(fmt, args));
1545        }
1546
1547        // ---- Misc helpers ---------------------------------------------------------
1548
1549        private static CacheMode parseCacheMode(String option) {
1550                if (option == null || option.isBlank()) return CacheMode.SIDECAR;
1551
1552                String normalized = option.trim().toLowerCase(Locale.ROOT);
1553                switch (normalized) {
1554                        case "none":
1555                        case "off":
1556                        case "false":
1557                                return CacheMode.NONE;
1558                        case "sidecar":
1559                                return CacheMode.SIDECAR;
1560                        case "persistent":
1561                        case "persist":
1562                                return CacheMode.PERSISTENT;
1563                        default:
1564                                // Unknown -> default to sidecar for safety
1565                                return CacheMode.SIDECAR;
1566                }
1567        }
1568
1569        private static boolean parseBooleanishOption(String option) {
1570                if (option == null) return false;
1571                String normalized = option.trim();
1572                if (normalized.isEmpty()) return false;
1573                return !"false".equalsIgnoreCase(normalized);
1574        }
1575
1576        private static String hashPath(String input) {
1577                try {
1578                        MessageDigest digest = MessageDigest.getInstance("SHA-1");
1579                        byte[] bytes = digest.digest(input.getBytes(StandardCharsets.UTF_8));
1580                        return toHex(bytes);
1581                } catch (NoSuchAlgorithmException e) {
1582                        return Integer.toHexString(input.hashCode());
1583                }
1584        }
1585
1586        private static String toHex(byte[] bytes) {
1587                char[] out = new char[bytes.length * 2];
1588                char[] digits = "0123456789abcdef".toCharArray();
1589                for (int i = 0; i < bytes.length; i++) {
1590                        int v = bytes[i] & 0xFF;
1591                        out[i * 2] = digits[v >>> 4];
1592                        out[i * 2 + 1] = digits[v & 0x0F];
1593                }
1594                return new String(out);
1595        }
1596
1597        private static int countPathSegments(String path) {
1598                int count = 1;
1599                for (int i = 0; i < path.length(); i++) {
1600                        if (path.charAt(i) == '/') count++;
1601                }
1602                return count;
1603        }
1604
1605        private static String generateKey(ResourceMethodDeclaration r) {
1606                return r.httpMethod().name() + "|" + r.path() + "|" + r.className() + "|" +
1607                                r.methodName() + "|" + String.join(";", r.parameterTypes()) + "|" +
1608                                r.sseEventSource();
1609        }
1610
1611        private static String generateMcpEndpointKey(McpEndpointDeclaration endpointDeclaration) {
1612                return endpointDeclaration.className();
1613        }
1614
1615        private static List<ResourceMethodDeclaration> dedupeAndOrder(List<ResourceMethodDeclaration> in) {
1616                Map<String, ResourceMethodDeclaration> byKey = new LinkedHashMap<>();
1617                for (ResourceMethodDeclaration r : in) byKey.putIfAbsent(generateKey(r), r);
1618
1619                List<ResourceMethodDeclaration> out = new ArrayList<>(byKey.values());
1620                out.sort(Comparator
1621                                .comparing((ResourceMethodDeclaration r) -> r.httpMethod().name())
1622                                .thenComparing(ResourceMethodDeclaration::path)
1623                                .thenComparing(ResourceMethodDeclaration::className)
1624                                .thenComparing(ResourceMethodDeclaration::methodName));
1625                return out;
1626        }
1627
1628        private static List<McpEndpointDeclaration> dedupeAndOrderMcpEndpoints(List<McpEndpointDeclaration> in) {
1629                Map<String, McpEndpointDeclaration> byKey = new LinkedHashMap<>();
1630                for (McpEndpointDeclaration endpointDeclaration : in)
1631                        byKey.putIfAbsent(generateMcpEndpointKey(endpointDeclaration), endpointDeclaration);
1632
1633                List<McpEndpointDeclaration> out = new ArrayList<>(byKey.values());
1634                out.sort(Comparator.comparing(McpEndpointDeclaration::className));
1635                return out;
1636        }
1637
1638        private record ResourceMethodSpecificityKey(HttpMethod httpMethod,
1639                                                                                                                                                                                        Boolean sseEventSource,
1640                                                                                                                                                                                        Boolean hasVarargs,
1641                                                                                                                                                                                        Long placeholderCount,
1642                                                                                                                                                                                        Long literalCount) {}
1643
1644        private record McpEndpointDeclaration(String className) {}
1645}