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