Skip to content

Commit

Permalink
Merge pull request #73 from mkouba/issue-71-complete-resources
Browse files Browse the repository at this point in the history
Implement completion for resource templates
  • Loading branch information
mkouba authored Jan 17, 2025
2 parents cdcfa31 + 1a9ebe1 commit 03907e6
Show file tree
Hide file tree
Showing 24 changed files with 457 additions and 56 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import io.quarkiverse.mcp.server.BlobResourceContents;
import io.quarkiverse.mcp.server.CompleteArg;
import io.quarkiverse.mcp.server.CompletePrompt;
import io.quarkiverse.mcp.server.CompleteResourceTemplate;
import io.quarkiverse.mcp.server.CompletionResponse;
import io.quarkiverse.mcp.server.Content;
import io.quarkiverse.mcp.server.ImageContent;
Expand Down Expand Up @@ -64,6 +65,7 @@ class DotNames {
static final DotName BLOB_RESOURCE_CONTENTS = DotName.createSimple(BlobResourceContents.class);
static final DotName STRING = DotName.createSimple(String.class);
static final DotName COMPLETE_PROMPT = DotName.createSimple(CompletePrompt.class);
static final DotName COMPLETE_RESOURCE_TEMPLATE = DotName.createSimple(CompleteResourceTemplate.class);
static final DotName COMPLETE_ARG = DotName.createSimple(CompleteArg.class);
static final DotName COMPLETE_RESPONSE = DotName.createSimple(CompletionResponse.class);
static final DotName RESOURCE_TEMPLATE = DotName.createSimple(ResourceTemplate.class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ boolean isResourceTemplate() {
return feature == Feature.RESOURCE_TEMPLATE;
}

boolean isResourceTemplateComplete() {
return feature == Feature.RESOURCE_TEMPLATE_COMPLETE;
}

@Override
public String toString() {
return "FeatureMethodBuildItem [name=" + name + ", method=" + method.declaringClass() + "#"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import static io.quarkiverse.mcp.server.runtime.FeatureMetadata.Feature.PROMPT_COMPLETE;
import static io.quarkiverse.mcp.server.runtime.FeatureMetadata.Feature.RESOURCE;
import static io.quarkiverse.mcp.server.runtime.FeatureMetadata.Feature.RESOURCE_TEMPLATE;
import static io.quarkiverse.mcp.server.runtime.FeatureMetadata.Feature.RESOURCE_TEMPLATE_COMPLETE;
import static io.quarkiverse.mcp.server.runtime.FeatureMetadata.Feature.TOOL;
import static io.quarkus.deployment.annotations.ExecutionTime.RUNTIME_INIT;

Expand Down Expand Up @@ -53,6 +54,7 @@
import io.quarkiverse.mcp.server.runtime.PromptCompleteManager;
import io.quarkiverse.mcp.server.runtime.PromptManager;
import io.quarkiverse.mcp.server.runtime.ResourceManager;
import io.quarkiverse.mcp.server.runtime.ResourceTemplateCompleteManager;
import io.quarkiverse.mcp.server.runtime.ResourceTemplateManager;
import io.quarkiverse.mcp.server.runtime.ResultMappers;
import io.quarkiverse.mcp.server.runtime.ToolManager;
Expand Down Expand Up @@ -92,14 +94,15 @@ class McpServerProcessor {
DotNames.COMPLETE_PROMPT, PROMPT_COMPLETE,
DotNames.RESOURCE, RESOURCE,
DotNames.RESOURCE_TEMPLATE, RESOURCE_TEMPLATE,
DotNames.COMPLETE_RESOURCE_TEMPLATE, RESOURCE_TEMPLATE_COMPLETE,
DotNames.TOOL, TOOL);

@BuildStep
void addBeans(BuildProducer<AdditionalBeanBuildItem> additionalBeans) {
additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf("io.quarkiverse.mcp.server.runtime.ConnectionManager"));
additionalBeans.produce(AdditionalBeanBuildItem.builder().setUnremovable()
.addBeanClasses(PromptManager.class, ToolManager.class, ResourceManager.class, PromptCompleteManager.class,
ResourceTemplateManager.class)
ResourceTemplateManager.class, ResourceTemplateCompleteManager.class)
.build());
}

Expand All @@ -124,7 +127,7 @@ void collectFeatureMethods(BeanDiscoveryFinishedBuildItem beanDiscovery, Invoker
Feature feature = getFeature(featureAnnotation);
validateFeatureMethod(method, feature);
String name;
if (feature == PROMPT_COMPLETE) {
if (feature == PROMPT_COMPLETE || feature == RESOURCE_TEMPLATE_COMPLETE) {
name = featureAnnotation.value().asString();
} else {
AnnotationValue nameValue = featureAnnotation.value("name");
Expand Down Expand Up @@ -201,7 +204,7 @@ void collectFeatureMethods(BeanDiscoveryFinishedBuildItem beanDiscovery, Invoker
}
}

// Check existing prompts for prompt completions
// Check existing prompts for completions
List<FeatureMethodBuildItem> prompts = found.get(PROMPT);
List<FeatureMethodBuildItem> promptCompletions = found.get(PROMPT_COMPLETE);
if (promptCompletions != null) {
Expand All @@ -213,10 +216,24 @@ void collectFeatureMethods(BeanDiscoveryFinishedBuildItem beanDiscovery, Invoker
}
}
}

// Check existing resource templates for completions
List<FeatureMethodBuildItem> resourceTemplates = found.get(RESOURCE_TEMPLATE);
List<FeatureMethodBuildItem> resourceTemplateCompletions = found.get(RESOURCE_TEMPLATE_COMPLETE);
if (resourceTemplateCompletions != null) {
for (FeatureMethodBuildItem completion : resourceTemplateCompletions) {
if (resourceTemplates == null
|| resourceTemplates.stream().noneMatch(p -> p.getName().equals(completion.getName()))) {
String message = "Resource template %s does not exist for completion: %s"
.formatted(completion.getName(), completion);
errors.produce(new ValidationErrorBuildItem(new IllegalStateException(message)));
}
}
}
}

private String getDuplicateValidationName(FeatureMethodBuildItem featureMethod) {
if (featureMethod.getFeature() == PROMPT_COMPLETE) {
if (featureMethod.getFeature() == PROMPT_COMPLETE || featureMethod.getFeature() == RESOURCE_TEMPLATE_COMPLETE) {
MethodParameterInfo argument = featureMethod.getMethod().parameters().stream()
.filter(p -> providerFrom(p.type()) == Provider.PARAMS).findFirst().orElseThrow();
String argumentName = argument.name();
Expand Down Expand Up @@ -316,6 +333,20 @@ void generateMetadata(McpServerRecorder recorder, RecorderContext recorderContex
}
resourceTemplatesMethod.returnValue(retResourceTemplates);

// io.quarkiverse.mcp.server.runtime.McpMetadata.resourceTemplateCompletions()
MethodCreator resourceTemplateCompletionsMethod = metadataCreator.getMethodCreator("resourceTemplateCompletions",
List.class);
ResultHandle retResourceTemplateCompletions = Gizmo.newArrayList(resourceTemplateCompletionsMethod);
for (FeatureMethodBuildItem resourceTemplateCompletion : featureMethods.stream()
.filter(FeatureMethodBuildItem::isResourceTemplateComplete)
.toList()) {
processFeatureMethod(counter, metadataCreator, resourceTemplateCompletionsMethod, resourceTemplateCompletion,
retResourceTemplateCompletions,
transformedAnnotations,
DotNames.COMPLETE_ARG);
}
resourceTemplateCompletionsMethod.returnValue(retResourceTemplateCompletions);

metadataCreator.close();

syntheticBeans.produce(SyntheticBeanBuildItem.configure(McpMetadata.class)
Expand Down Expand Up @@ -362,6 +393,7 @@ private void validateFeatureMethod(MethodInfo method, Feature feature) {
case TOOL -> validateToolMethod(method);
case RESOURCE -> validateResourceMethod(method);
case RESOURCE_TEMPLATE -> validateResourceTemplateMethod(method);
case RESOURCE_TEMPLATE_COMPLETE -> validateResourceTemplateCompleteMethod(method);
default -> throw new IllegalArgumentException("Unsupported feature: " + feature);
}
}
Expand Down Expand Up @@ -390,7 +422,7 @@ private void validatePromptMethod(MethodInfo method) {
}
}

private static final Set<org.jboss.jandex.Type> PROMPT_COMPLETE_TYPES = Set.of(ClassType.create(DotNames.COMPLETE_RESPONSE),
private static final Set<org.jboss.jandex.Type> COMPLETE_TYPES = Set.of(ClassType.create(DotNames.COMPLETE_RESPONSE),
ClassType.create(DotNames.STRING));

private void validatePromptCompleteMethod(MethodInfo method) {
Expand All @@ -401,7 +433,7 @@ private void validatePromptCompleteMethod(MethodInfo method) {
if (DotNames.LIST.equals(type.name()) && type.kind() == Kind.PARAMETERIZED_TYPE) {
type = type.asParameterizedType().arguments().get(0);
}
if (!PROMPT_COMPLETE_TYPES.contains(type)) {
if (!COMPLETE_TYPES.contains(type)) {
throw new IllegalStateException("Unsupported Prompt complete method return type: " + method);
}

Expand All @@ -412,6 +444,25 @@ private void validatePromptCompleteMethod(MethodInfo method) {
}
}

private void validateResourceTemplateCompleteMethod(MethodInfo method) {
org.jboss.jandex.Type type = method.returnType();
if (DotNames.UNI.equals(type.name()) && type.kind() == Kind.PARAMETERIZED_TYPE) {
type = type.asParameterizedType().arguments().get(0);
}
if (DotNames.LIST.equals(type.name()) && type.kind() == Kind.PARAMETERIZED_TYPE) {
type = type.asParameterizedType().arguments().get(0);
}
if (!COMPLETE_TYPES.contains(type)) {
throw new IllegalStateException("Unsupported Resource template complete method return type: " + method);
}

List<MethodParameterInfo> arguments = method.parameters().stream()
.filter(p -> providerFrom(p.type()) == Provider.PARAMS).toList();
if (arguments.size() != 1 || !arguments.get(0).type().name().equals(DotNames.STRING)) {
throw new IllegalStateException("Resource template complete must consume exactly one String argument: " + method);
}
}

private static final Set<org.jboss.jandex.Type> TOOL_TYPES = Set.of(ClassType.create(DotNames.TOOL_RESPONSE),
ClassType.create(DotNames.CONTENT), ClassType.create(DotNames.TEXT_CONTENT),
ClassType.create(DotNames.IMAGE_CONTENT), ClassType.create(DotNames.RESOURCE_CONTENT),
Expand Down Expand Up @@ -475,7 +526,8 @@ private boolean hasFeatureMethod(BeanInfo bean) {
|| beanClass.hasAnnotation(DotNames.COMPLETE_PROMPT)
|| beanClass.hasAnnotation(DotNames.TOOL)
|| beanClass.hasAnnotation(DotNames.RESOURCE)
|| beanClass.hasAnnotation(DotNames.RESOURCE_TEMPLATE);
|| beanClass.hasAnnotation(DotNames.RESOURCE_TEMPLATE)
|| beanClass.hasAnnotation(DotNames.COMPLETE_RESOURCE_TEMPLATE);
}

private void processFeatureMethod(AtomicInteger counter, ClassCreator clazz, MethodCreator method,
Expand Down Expand Up @@ -561,6 +613,8 @@ private ResultHandle getMapper(BytecodeCreator bytecode, org.jboss.jandex.Type r
}));
case RESOURCE, RESOURCE_TEMPLATE -> readResultMapper(bytecode,
createMapperField(RESOURCE, returnType, DotNames.RESOURCE_RESPONSE, c -> "CONTENT"));
case RESOURCE_TEMPLATE_COMPLETE -> readResultMapper(bytecode,
createMapperField(RESOURCE_TEMPLATE_COMPLETE, returnType, DotNames.COMPLETE_RESPONSE, c -> "STRING"));
default -> throw new IllegalArgumentException("Unsupported feature: " + feature);
};
}
Expand All @@ -571,9 +625,13 @@ static String createMapperField(FeatureMetadata.Feature feature, org.jboss.jande
return "TO_UNI";
}
org.jboss.jandex.Type type = returnType;
StringBuilder ret = new StringBuilder(feature.toString())
.append("_");

StringBuilder ret;
if (feature == PROMPT_COMPLETE || feature == RESOURCE_TEMPLATE_COMPLETE) {
ret = new StringBuilder("COMPLETE_");
} else {
ret = new StringBuilder(feature.toString())
.append("_");
}
if (DotNames.UNI.equals(type.name())) {
type = type.asParameterizedType().arguments().get(0);
if (type.name().equals(baseType)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package io.quarkiverse.mcp.server;

import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.util.List;

import io.smallrye.mutiny.Uni;

/**
* Annotates a business method of a CDI bean used to complete an expression of a URI template of a resource template.
* <p>
* The result of a "complete" operation is always represented as a {@link CompletionResponse}. However, the annotated method can
* also return other types that are converted according to the following rules.
* <ul>
* <li>If the method returns {@link String} then the reponse contains the single value.</li>
* <li>If the method returns a {@link List} of {@link String}s then the reponse contains the list of values.</li>
* <li>The method may return a {@link Uni} that wraps any of the type mentioned above.</li>
* </ul>
* In other words, the return type must be one of the following list:
* <ul>
* <li>{@code CompletionResponse}</li>
* <li>{@code String}</li>
* <li>{@code List<String>}</li>
* <li>{@code Uni<CompletionResponse>}</li>
* <li>{@code Uni<String>}</li>
* <li>{@code Uni<List<String>>}</li>
* </ul>
*
* @see ResourceTemplate#name()
*/
@Retention(RUNTIME)
@Target(METHOD)
public @interface CompleteResourceTemplate {

/**
* The name reference to a resource template. If not such {@link ResourceTemplate} exists then the build fails.
*/
String value();

}
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,25 @@
import io.vertx.core.Handler;
import io.vertx.core.json.JsonObject;

class PromptCompletionMessageHandler {
public abstract class CompletionMessageHandler {

private static final Logger LOG = Logger.getLogger(PromptCompletionMessageHandler.class);
private static final Logger LOG = Logger.getLogger(CompletionMessageHandler.class);

private final PromptCompleteManager manager;
protected abstract Future<CompletionResponse> execute(String key, ArgumentProviders argProviders) throws McpException;

PromptCompletionMessageHandler(PromptCompleteManager manager) {
this.manager = manager;
}

void promptComplete(Object id, JsonObject ref, JsonObject argument, Responder responder, McpConnection connection) {
String promptName = ref.getString("name");
void complete(Object id, JsonObject ref, JsonObject argument, Responder responder, McpConnection connection) {
String referenceName = ref.getString("name");
String argumentName = argument.getString("name");

LOG.debugf("Complete prompt %s for argument %s [id: %s]", promptName, argumentName, id);
LOG.debugf("Complete %s for argument %s [id: %s]", referenceName, argumentName, id);

String key = promptName + "_" + argumentName;
String key = referenceName + "_" + argumentName;

ArgumentProviders argProviders = new ArgumentProviders(
Map.of(argumentName, argument.getString("value")), connection, id, responder);

try {
Future<CompletionResponse> fu = manager.execute(key, argProviders);
Future<CompletionResponse> fu = execute(key, argProviders);
fu.onComplete(new Handler<AsyncResult<CompletionResponse>>() {
@Override
public void handle(AsyncResult<CompletionResponse> ar) {
Expand All @@ -51,7 +47,7 @@ public void handle(AsyncResult<CompletionResponse> ar) {
result.put("completion", completion);
responder.sendResult(id, result);
} else {
LOG.errorf(ar.cause(), "Unable to complete prompt %s", promptName);
LOG.errorf(ar.cause(), "Unable to complete prompt %s", referenceName);
responder.sendInternalError(id);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ public enum Feature {
TOOL,
RESOURCE,
RESOURCE_TEMPLATE,
PROMPT_COMPLETE;
PROMPT_COMPLETE,
RESOURCE_TEMPLATE_COMPLETE;

public boolean requiresUri() {
return this == RESOURCE || this == RESOURCE_TEMPLATE;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,23 +24,25 @@ public class McpMessageHandler {

private final ToolMessageHandler toolHandler;
private final PromptMessageHandler promptHandler;
private final PromptCompletionMessageHandler promptCompleteHandler;
private final PromptCompleteMessageHandler promptCompleteHandler;
private final ResourceMessageHandler resourceHandler;
private final ResourceTemplateMessageHandler resourceTemplateHandler;
private final ResourceTemplateCompleteMessageHandler resourceTemplateCompleteHandler;

protected final McpRuntimeConfig config;

private final Map<String, Object> serverInfo;

protected McpMessageHandler(McpRuntimeConfig config, ConnectionManager connectionManager, PromptManager promptManager,
ToolManager toolManager, ResourceManager resourceManager, PromptCompleteManager promptCompleteManager,
ResourceTemplateManager resourceTemplateManager) {
ResourceTemplateManager resourceTemplateManager, ResourceTemplateCompleteManager resourceTemplateCompleteManager) {
this.connectionManager = connectionManager;
this.toolHandler = new ToolMessageHandler(toolManager);
this.promptHandler = new PromptMessageHandler(promptManager);
this.promptCompleteHandler = new PromptCompletionMessageHandler(promptCompleteManager);
this.promptCompleteHandler = new PromptCompleteMessageHandler(promptCompleteManager);
this.resourceHandler = new ResourceMessageHandler(resourceManager);
this.resourceTemplateHandler = new ResourceTemplateMessageHandler(resourceTemplateManager);
this.resourceTemplateCompleteHandler = new ResourceTemplateCompleteMessageHandler(resourceTemplateCompleteManager);
this.config = config;
this.serverInfo = serverInfo(promptManager, toolManager, resourceManager, resourceTemplateManager);
}
Expand Down Expand Up @@ -167,7 +169,9 @@ private void complete(JsonObject message, Responder responder, McpConnection con
responder.sendError(id, JsonRPC.INVALID_REQUEST, "Argument not found");
} else {
if ("ref/prompt".equals(referenceType)) {
promptCompleteHandler.promptComplete(id, ref, argument, responder, connection);
promptCompleteHandler.complete(id, ref, argument, responder, connection);
} else if ("ref/resource".equals(referenceType)) {
resourceTemplateCompleteHandler.complete(id, ref, argument, responder, connection);
} else {
responder.sendError(id, JsonRPC.INVALID_REQUEST,
"Unsupported reference found: " + ref.getString("type"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,6 @@ public interface McpMetadata {

List<FeatureMetadata<ResourceResponse>> resourceTemplates();

List<FeatureMetadata<CompletionResponse>> resourceTemplateCompletions();

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package io.quarkiverse.mcp.server.runtime;

import java.util.Objects;

import io.quarkiverse.mcp.server.CompletionResponse;
import io.vertx.core.Future;

class PromptCompleteMessageHandler extends CompletionMessageHandler {

private final PromptCompleteManager manager;

PromptCompleteMessageHandler(PromptCompleteManager manager) {
this.manager = Objects.requireNonNull(manager);
}

@Override
protected Future<CompletionResponse> execute(String key, ArgumentProviders argProviders) throws McpException {
return manager.execute(key, argProviders);
}

}
Loading

0 comments on commit 03907e6

Please sign in to comment.