001/*
002 * Copyright 2022-2026 Revetware LLC.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package com.soklet;
018
019import org.jspecify.annotations.NonNull;
020import org.jspecify.annotations.Nullable;
021
022import javax.annotation.concurrent.NotThreadSafe;
023import javax.annotation.concurrent.ThreadSafe;
024import java.util.Collections;
025import java.util.LinkedHashMap;
026import java.util.LinkedHashSet;
027import java.util.List;
028import java.util.Map;
029import java.util.Optional;
030import java.util.Set;
031
032import static java.util.Objects.requireNonNull;
033
034/**
035 * Immutable programmatic registration for one MCP prompt.
036 *
037 * <p>Registration is staged so a handler must be supplied before optional
038 * descriptor metadata can be configured and the registration built. Prompt
039 * arguments are flat strings and do not use JSON Schema.
040 *
041 * @author <a href="https://www.revetkn.com">Mark Allen</a>
042 */
043@ThreadSafe
044public final class McpPromptRegistration {
045        @NonNull
046        private final String name;
047        @NonNull
048        private final Set<@NonNull McpProtocolVersion> protocolVersions;
049        @Nullable
050        private final String title;
051        @Nullable
052        private final String description;
053        @NonNull
054        private final List<@NonNull McpIcon> icons;
055        @NonNull
056        private final List<@NonNull McpPromptArgumentDeclaration> arguments;
057        @NonNull
058        private final List<@NonNull McpInputRequestDeclaration> inputRequestDeclarations;
059        @NonNull
060        private final McpRequestStateMode requestStateMode;
061        @NonNull
062        private final McpJsonObject metadata;
063        @NonNull
064        private final McpPromptHandler handler;
065        @Nullable
066        private final McpCompletionHandler completionHandler;
067        @NonNull
068        private final Set<@NonNull McpProtocolVersion> completionProtocolVersions;
069
070        /**
071         * Begins a staged registration for a named prompt.
072         *
073         * @param name nonblank prompt name published to MCP clients
074         * @param protocolVersions nonempty exact revisions serving this prompt
075         * @return required-handler stage
076         * @throws IllegalArgumentException if {@code name} is blank
077         */
078        @NonNull
079        public static HandlerStage withName(@NonNull String name,
080                        @NonNull Set<@NonNull McpProtocolVersion> protocolVersions) {
081                return new HandlerStage(requireName(name),
082                                McpProtocolVersion.requiredSet(protocolVersions));
083        }
084
085        private McpPromptRegistration(@NonNull Builder builder) {
086                this.name = builder.name;
087                this.protocolVersions = builder.protocolVersions;
088                this.title = builder.title;
089                this.description = builder.description;
090                this.icons = List.copyOf(builder.icons);
091                this.arguments = immutableArguments(builder.arguments);
092                this.inputRequestDeclarations =
093                                List.copyOf(builder.inputRequestDeclarations);
094                this.requestStateMode = builder.requestStateMode;
095                this.metadata = builder.metadata;
096                this.handler = builder.handler;
097                this.completionHandler = builder.completionHandler;
098                this.completionProtocolVersions = builder.completionProtocolVersions;
099        }
100
101        /** @return published prompt name */
102        @NonNull
103        public String getName() {
104                return this.name;
105        }
106
107        /** @return exact protocol revisions on which this prompt is available */
108        @NonNull
109        public Set<@NonNull McpProtocolVersion> getProtocolVersions() {
110                return this.protocolVersions;
111        }
112
113        /** @return human-readable title, if configured */
114        @NonNull
115        public Optional<@NonNull String> getTitle() {
116                return Optional.ofNullable(this.title);
117        }
118
119        /** @return human-readable description, if configured */
120        @NonNull
121        public Optional<@NonNull String> getDescription() {
122                return Optional.ofNullable(this.description);
123        }
124
125        /** @return immutable icon list in registration order */
126        @NonNull
127        public List<@NonNull McpIcon> getIcons() {
128                return this.icons;
129        }
130
131        /** @return immutable argument declarations in registration order */
132        @NonNull
133        public List<@NonNull McpPromptArgumentDeclaration> getArguments() {
134                return this.arguments;
135        }
136
137        /**
138         * Returns the input requests this prompt operation may emit.
139         *
140         * @return immutable declarations in registration order
141         */
142        @NonNull
143        public List<@NonNull McpInputRequestDeclaration>
144                        getInputRequestDeclarations() {
145                return this.inputRequestDeclarations;
146        }
147
148        /**
149         * Returns the request-state contract for this prompt operation.
150         *
151         * @return request-state mode
152         */
153        @NonNull
154        public McpRequestStateMode getRequestStateMode() {
155                return this.requestStateMode;
156        }
157
158        /** @return immutable protocol extension metadata */
159        @NonNull
160        public McpJsonObject getMetadata() {
161                return this.metadata;
162        }
163
164        /** @return prompt handler */
165        @NonNull
166        public McpPromptHandler getHandler() {
167                return this.handler;
168        }
169
170        /** @return configured argument completer, or empty when omitted */
171        @NonNull
172        public Optional<@NonNull McpCompletionHandler> getCompletionHandler() {
173                return Optional.ofNullable(this.completionHandler);
174        }
175
176        /** @return exact revisions on which the completion handler is available */
177        @NonNull
178        public Set<@NonNull McpProtocolVersion> getCompletionProtocolVersions() {
179                return this.completionProtocolVersions;
180        }
181
182        /**
183         * Validates raw string arguments and invokes the prompt handler.
184         */
185        @NonNull
186        McpOperationResult invoke(@NonNull McpRequestContext request,
187                        @NonNull McpJsonObject rawArguments,
188                        @NonNull McpInvocationFeatures features) throws Exception {
189                requireNonNull(request);
190                requireNonNull(rawArguments);
191                requireNonNull(features);
192
193                Map<String, McpPromptArgumentDeclaration> definitions =
194                                new LinkedHashMap<>();
195                for (McpPromptArgumentDeclaration argument : this.arguments)
196                        definitions.put(argument.getName(), argument);
197
198                Map<String, String> values = new LinkedHashMap<>();
199                for (Map.Entry<String, McpJsonValue> entry
200                                : rawArguments.getMembers().entrySet()) {
201                        if (!definitions.containsKey(entry.getKey())
202                                        || !(entry.getValue() instanceof McpJsonString string))
203                                throw new McpInvalidPromptArgumentsException();
204                        values.put(entry.getKey(), string.getValue());
205                }
206                for (McpPromptArgumentDeclaration argument : this.arguments) {
207                        if (argument.isRequired() && !values.containsKey(argument.getName()))
208                                throw new McpInvalidPromptArgumentsException();
209                }
210
211                McpPromptGetContext prompt = new DefaultPromptGetContext(values);
212                return requireNonNull(this.handler.handle(request, prompt, features),
213                                "The MCP prompt handler returned null.");
214        }
215
216        @NonNull
217        private static String requireName(@NonNull String name) {
218                McpContentValueSupport.requireWellFormedString(name);
219                if (name.isBlank())
220                        throw new IllegalArgumentException(
221                                        "MCP prompt names must not be blank.");
222                return name;
223        }
224
225        @NonNull
226        private static List<@NonNull McpPromptArgumentDeclaration> immutableArguments(
227                        @NonNull List<@NonNull McpPromptArgumentDeclaration> arguments) {
228                List<McpPromptArgumentDeclaration> copied = List.copyOf(arguments);
229                Set<String> names = new LinkedHashSet<>();
230                for (McpPromptArgumentDeclaration argument : copied) {
231                        if (!names.add(argument.getName()))
232                                throw new IllegalStateException(
233                                                "Duplicate MCP prompt argument name: "
234                                                                + argument.getName());
235                }
236                return copied;
237        }
238
239        /**
240         * Required-handler stage for a named prompt.
241         *
242         * @author <a href="https://www.revetkn.com">Mark Allen</a>
243         */
244        @NotThreadSafe
245        public static final class HandlerStage {
246                @NonNull
247                private final String name;
248                @NonNull
249                private final Set<@NonNull McpProtocolVersion> protocolVersions;
250
251                private HandlerStage(@NonNull String name,
252                                @NonNull Set<@NonNull McpProtocolVersion> protocolVersions) {
253                        this.name = McpContentValueSupport.requireWellFormedString(name);
254                        this.protocolVersions = requireNonNull(protocolVersions);
255                }
256
257                /**
258                 * Supplies the required prompt handler.
259                 *
260                 * @param handler prompt handler
261                 * @return optional-metadata builder
262                 */
263                @NonNull
264                public Builder handler(@NonNull McpPromptHandler handler) {
265                        return new Builder(this.name, this.protocolVersions,
266                                        requireNonNull(handler));
267                }
268        }
269
270        /**
271         * Mutable builder for an immutable prompt registration.
272         *
273         * @author <a href="https://www.revetkn.com">Mark Allen</a>
274         */
275        @NotThreadSafe
276        public static final class Builder {
277                @NonNull
278                private final String name;
279                @NonNull
280                private final Set<@NonNull McpProtocolVersion> protocolVersions;
281                @NonNull
282                private final McpPromptHandler handler;
283                @Nullable
284                private String title;
285                @Nullable
286                private String description;
287                @NonNull
288                private List<@NonNull McpIcon> icons = List.of();
289                @NonNull
290                private List<@NonNull McpPromptArgumentDeclaration> arguments =
291                                List.of();
292                @NonNull
293                private List<@NonNull McpInputRequestDeclaration>
294                                inputRequestDeclarations = List.of();
295                @NonNull
296                private McpRequestStateMode requestStateMode = McpRequestStateMode.NONE;
297                @NonNull
298                private McpJsonObject metadata = McpJsonObject.emptyInstance();
299                @Nullable
300                private McpCompletionHandler completionHandler;
301                @NonNull
302                private Set<@NonNull McpProtocolVersion> completionProtocolVersions = Set.of();
303
304                private Builder(@NonNull String name,
305                                @NonNull Set<@NonNull McpProtocolVersion> protocolVersions,
306                                @NonNull McpPromptHandler handler) {
307                        this.name = McpContentValueSupport.requireWellFormedString(name);
308                        this.protocolVersions = requireNonNull(protocolVersions);
309                        this.handler = requireNonNull(handler);
310                }
311
312                /** @param title human-readable title
313                 * @return this builder */
314                @NonNull
315                public Builder title(@NonNull String title) {
316                        this.title = McpContentValueSupport.requireWellFormedString(title);
317                        return this;
318                }
319
320                /** @param description human-readable description
321                 * @return this builder */
322                @NonNull
323                public Builder description(@NonNull String description) {
324                        this.description = McpContentValueSupport.requireWellFormedString(description);
325                        return this;
326                }
327
328                /**
329                 * Replaces icon descriptors in supplied order.
330                 * Null or empty clears the property. The complete list is validated and
331                 * snapshotted before replacing the prior value.
332                 *
333                 * @param icons icon descriptors, or null to clear
334                 * @return this builder
335                 * @throws NullPointerException if a list element is null
336                 */
337                @NonNull
338                public Builder icons(
339                                @Nullable List<@NonNull McpIcon> icons) {
340                        this.icons = icons == null ? List.of()
341                                        : List.copyOf(icons);
342                        return this;
343                }
344
345                /**
346                 * Replaces prompt-argument declarations in supplied order.
347                 * Null or empty clears the property. The complete list is validated and
348                 * snapshotted before replacing the prior value.
349                 *
350                 * @param argumentDeclarations prompt-argument declarations, or null to clear
351                 * @return this builder
352                 * @throws NullPointerException if a list element is null
353                 */
354                @NonNull
355                public Builder arguments(
356                                @Nullable List<@NonNull McpPromptArgumentDeclaration> argumentDeclarations) {
357                        this.arguments = argumentDeclarations == null ? List.of()
358                                        : List.copyOf(argumentDeclarations);
359                        return this;
360                }
361
362                /**
363                 * Replaces input-request declarations in supplied order.
364                 * Null or empty clears the property. The complete list is validated and
365                 * snapshotted before replacing the prior value.
366                 *
367                 * @param inputRequestDeclarations input-request declarations, or null to clear
368                 * @return this builder
369                 * @throws NullPointerException if a list element is null
370                 */
371                @NonNull
372                public Builder inputRequestDeclarations(
373                                @Nullable List<@NonNull McpInputRequestDeclaration> inputRequestDeclarations) {
374                        this.inputRequestDeclarations = inputRequestDeclarations == null ? List.of()
375                                        : List.copyOf(inputRequestDeclarations);
376                        return this;
377                }
378
379                /**
380                 * Sets the request-state contract for this prompt operation.
381                 *
382                 * @param requestStateMode request-state mode
383                 * @return this builder
384                 */
385                @NonNull
386                public Builder requestStateMode(
387                                @NonNull McpRequestStateMode requestStateMode) {
388                        this.requestStateMode = requireNonNull(requestStateMode);
389                        return this;
390                }
391
392                /** @param metadata protocol extension metadata
393                 * @return this builder */
394                @NonNull
395                public Builder metadata(@NonNull McpJsonObject metadata) {
396                        this.metadata = requireNonNull(metadata);
397                        return this;
398                }
399
400                /**
401                 * Configures the handler for partial prompt-argument suggestions.
402                 * Completion is supported for MCP 2025-06-18, 2025-11-25, and
403                 * 2026-07-28. Repeated calls replace the previous handler and revision
404                 * selection.
405                 *
406                 * @param completionHandler non-null argument completer
407                 * @param protocolVersions nonempty subset of this prompt's revisions
408                 * @return this builder
409                 */
410                @NonNull
411                public Builder completionHandler(
412                                @NonNull McpCompletionHandler completionHandler,
413                                @NonNull Set<@NonNull McpProtocolVersion> protocolVersions) {
414                        requireNonNull(completionHandler);
415                        Set<McpProtocolVersion> versions = McpProtocolVersion.requiredSet(protocolVersions);
416                        if (!this.protocolVersions.containsAll(versions))
417                                throw new IllegalArgumentException(
418                                                "MCP prompt completion revisions must be a subset of prompt revisions.");
419                        this.completionHandler = completionHandler;
420                        this.completionProtocolVersions = versions;
421                        return this;
422                }
423
424                /**
425                 * Builds an immutable prompt registration.
426                 *
427                 * @return prompt registration
428                 * @throws IllegalStateException if argument names are duplicated
429                 */
430                @NonNull
431                public McpPromptRegistration build() {
432                        return new McpPromptRegistration(this);
433                }
434        }
435
436        /**
437         * Immutable prompt argument projection.
438         *
439         * @author <a href="https://www.revetkn.com">Mark Allen</a>
440         */
441        @ThreadSafe
442        private static final class DefaultPromptGetContext
443                        implements McpPromptGetContext {
444                @NonNull
445                private final Map<@NonNull String, @NonNull String> arguments;
446
447                private DefaultPromptGetContext(
448                                @NonNull Map<@NonNull String, @NonNull String> arguments) {
449                        this.arguments = Collections.unmodifiableMap(
450                                        new LinkedHashMap<>(requireNonNull(arguments)));
451                }
452
453                @Override
454                @NonNull
455                public Map<@NonNull String, @NonNull String> getArguments() {
456                        return this.arguments;
457                }
458
459                @Override
460                @NonNull
461                public Optional<@NonNull String> findArgument(@NonNull String name) {
462                        return Optional.ofNullable(this.arguments.get(McpContentValueSupport.requireWellFormedString(name)));
463                }
464        }
465}