From b756447d9601676628557edabab09288d8202b28 Mon Sep 17 00:00:00 2001 From: Martin Kouba Date: Wed, 15 Jan 2025 09:39:39 +0100 Subject: [PATCH] Implement resource templates --- .../mcp/server/deployment/DotNames.java | 10 +- .../deployment/FeatureMethodBuildItem.java | 6 +- .../server/deployment/McpServerProcessor.java | 214 ++++++++---------- .../deployment/McpServerProcessorTest.java | 26 +++ core/runtime/pom.xml | 5 + .../mcp/server/ResourceTemplate.java | 57 +++++ .../mcp/server/ResourceTemplateArg.java | 23 ++ .../mcp/server/runtime/FeatureManager.java | 10 +- .../mcp/server/runtime/FeatureMetadata.java | 10 +- .../mcp/server/runtime/McpMessageHandler.java | 16 +- .../mcp/server/runtime/McpMetadata.java | 2 + .../mcp/server/runtime/ResourceManager.java | 26 ++- .../runtime/ResourceTemplateManager.java | 93 ++++++++ .../ResourceTemplateMessageHandler.java | 29 +++ .../mcp/server/runtime/ResultMappers.java | 4 +- .../runtime/ResourceTemplateManagerTest.java | 29 +++ docs/modules/ROOT/pages/index.adoc | 36 +++ .../test/resources/templates/MyTemplates.java | 32 +++ .../templates/ResourceTemplatesTest.java | 75 ++++++ .../sse/runtime/SseMcpMessageHandler.java | 7 +- .../sse/runtime/SseMcpServerRecorder.java | 4 +- .../stdio/runtime/StdioMcpMessageHandler.java | 7 +- .../stdio/runtime/StdioMcpServerRecorder.java | 4 +- 23 files changed, 578 insertions(+), 147 deletions(-) create mode 100644 core/deployment/src/test/java/io/quarkiverse/mcp/server/deployment/McpServerProcessorTest.java create mode 100644 core/runtime/src/main/java/io/quarkiverse/mcp/server/ResourceTemplate.java create mode 100644 core/runtime/src/main/java/io/quarkiverse/mcp/server/ResourceTemplateArg.java create mode 100644 core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResourceTemplateManager.java create mode 100644 core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResourceTemplateMessageHandler.java create mode 100644 core/runtime/src/test/java/io/quarkiverse/mcp/server/runtime/ResourceTemplateManagerTest.java create mode 100644 transports/sse/deployment/src/test/java/io/quarkiverse/mcp/server/test/resources/templates/MyTemplates.java create mode 100644 transports/sse/deployment/src/test/java/io/quarkiverse/mcp/server/test/resources/templates/ResourceTemplatesTest.java diff --git a/core/deployment/src/main/java/io/quarkiverse/mcp/server/deployment/DotNames.java b/core/deployment/src/main/java/io/quarkiverse/mcp/server/deployment/DotNames.java index a954fef..f3028ad 100644 --- a/core/deployment/src/main/java/io/quarkiverse/mcp/server/deployment/DotNames.java +++ b/core/deployment/src/main/java/io/quarkiverse/mcp/server/deployment/DotNames.java @@ -20,6 +20,8 @@ import io.quarkiverse.mcp.server.ResourceContent; import io.quarkiverse.mcp.server.ResourceContents; import io.quarkiverse.mcp.server.ResourceResponse; +import io.quarkiverse.mcp.server.ResourceTemplate; +import io.quarkiverse.mcp.server.ResourceTemplateArg; import io.quarkiverse.mcp.server.TextContent; import io.quarkiverse.mcp.server.TextResourceContents; import io.quarkiverse.mcp.server.Tool; @@ -55,12 +57,14 @@ class DotNames { static final DotName RESOURCE_CONTENT = DotName.createSimple(ResourceContent.class); static final DotName RESOURCE = DotName.createSimple(Resource.class); static final DotName RESOURCE_RESPONSE = DotName.createSimple(ResourceResponse.class); - static final DotName RESOURCE_CONTENS = DotName.createSimple(ResourceContents.class); - static final DotName TEXT_RESOURCE_CONTENS = DotName.createSimple(TextResourceContents.class); - static final DotName BLOB_RESOURCE_CONTENS = DotName.createSimple(BlobResourceContents.class); + static final DotName RESOURCE_CONTENTS = DotName.createSimple(ResourceContents.class); + static final DotName TEXT_RESOURCE_CONTENTS = DotName.createSimple(TextResourceContents.class); + 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_ARG = DotName.createSimple(CompleteArg.class); static final DotName COMPLETE_RESPONSE = DotName.createSimple(CompletionResponse.class); + static final DotName RESOURCE_TEMPLATE = DotName.createSimple(ResourceTemplate.class); + static final DotName RESOURCE_TEMPLATE_ARG = DotName.createSimple(ResourceTemplateArg.class); } diff --git a/core/deployment/src/main/java/io/quarkiverse/mcp/server/deployment/FeatureMethodBuildItem.java b/core/deployment/src/main/java/io/quarkiverse/mcp/server/deployment/FeatureMethodBuildItem.java index 2d02c37..65c844d 100644 --- a/core/deployment/src/main/java/io/quarkiverse/mcp/server/deployment/FeatureMethodBuildItem.java +++ b/core/deployment/src/main/java/io/quarkiverse/mcp/server/deployment/FeatureMethodBuildItem.java @@ -37,7 +37,7 @@ final class FeatureMethodBuildItem extends MultiBuildItem { this.feature = Objects.requireNonNull(feature); this.name = Objects.requireNonNull(name); this.description = description; - this.uri = feature == Feature.RESOURCE ? Objects.requireNonNull(uri) : null; + this.uri = feature.requiresUri() ? Objects.requireNonNull(uri) : null; this.mimeType = mimeType; } @@ -89,6 +89,10 @@ boolean isResource() { return feature == Feature.RESOURCE; } + boolean isResourceTemplate() { + return feature == Feature.RESOURCE_TEMPLATE; + } + @Override public String toString() { return "FeatureMethodBuildItem [name=" + name + ", method=" + method.declaringClass() + "#" diff --git a/core/deployment/src/main/java/io/quarkiverse/mcp/server/deployment/McpServerProcessor.java b/core/deployment/src/main/java/io/quarkiverse/mcp/server/deployment/McpServerProcessor.java index 2843b0f..6642f1c 100644 --- a/core/deployment/src/main/java/io/quarkiverse/mcp/server/deployment/McpServerProcessor.java +++ b/core/deployment/src/main/java/io/quarkiverse/mcp/server/deployment/McpServerProcessor.java @@ -3,6 +3,7 @@ import static io.quarkiverse.mcp.server.runtime.FeatureMetadata.Feature.PROMPT; 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.TOOL; import static io.quarkus.deployment.annotations.ExecutionTime.RUNTIME_INIT; @@ -52,6 +53,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.ResourceTemplateManager; import io.quarkiverse.mcp.server.runtime.ResultMappers; import io.quarkiverse.mcp.server.runtime.ToolManager; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; @@ -85,18 +87,26 @@ class McpServerProcessor { + private static final Map FEATURES = Map.of( + DotNames.PROMPT, PROMPT, + DotNames.COMPLETE_PROMPT, PROMPT_COMPLETE, + DotNames.RESOURCE, RESOURCE, + DotNames.RESOURCE_TEMPLATE, RESOURCE_TEMPLATE, + DotNames.TOOL, TOOL); + @BuildStep void addBeans(BuildProducer 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) + .addBeanClasses(PromptManager.class, ToolManager.class, ResourceManager.class, PromptCompleteManager.class, + ResourceTemplateManager.class) .build()); } @BuildStep AutoAddScopeBuildItem autoAddScope() { return AutoAddScopeBuildItem.builder() - .containsAnnotations(DotNames.PROMPT, DotNames.TOOL, DotNames.RESOURCE, DotNames.COMPLETE_PROMPT) + .containsAnnotations(FEATURES.keySet().toArray(DotName[]::new)) .defaultScope(BuiltinScope.SINGLETON) .build(); } @@ -122,12 +132,23 @@ void collectFeatureMethods(BeanDiscoveryFinishedBuildItem beanDiscovery, Invoker } AnnotationValue descValue = featureAnnotation.value("description"); String description = descValue != null ? descValue.asString() : ""; + InvokerBuilder invokerBuilder = invokerFactory.createInvoker(bean, method) .withInstanceLookup(); - AnnotationValue uriValue = featureAnnotation.value("uri"); - String uri = uriValue != null ? uriValue.asString() : null; - AnnotationValue mimeTypeValue = featureAnnotation.value("mimeType"); - String mimeType = mimeTypeValue != null ? mimeTypeValue.asString() : null; + + String uri = null; + String mimeType = null; + if (feature == RESOURCE) { + AnnotationValue uriValue = featureAnnotation.value("uri"); + uri = uriValue != null ? uriValue.asString() : null; + AnnotationValue mimeTypeValue = featureAnnotation.value("mimeType"); + mimeType = mimeTypeValue != null ? mimeTypeValue.asString() : null; + } else if (feature == RESOURCE_TEMPLATE) { + AnnotationValue uriValue = featureAnnotation.value("uriTemplate"); + uri = uriValue != null ? uriValue.asString() : null; + AnnotationValue mimeTypeValue = featureAnnotation.value("mimeType"); + mimeType = mimeTypeValue != null ? mimeTypeValue.asString() : null; + } FeatureMethodBuildItem fm = new FeatureMethodBuildItem(bean, method, invokerBuilder.build(), name, description, uri, mimeType, feature); features.produce(fm); @@ -212,28 +233,18 @@ private String getDuplicateValidationName(FeatureMethodBuildItem featureMethod) } private AnnotationInstance getFeatureAnnotation(MethodInfo method) { - AnnotationInstance ret = method.declaredAnnotation(DotNames.PROMPT); - if (ret == null) { - ret = method.declaredAnnotation(DotNames.TOOL); - if (ret == null) { - ret = method.declaredAnnotation(DotNames.RESOURCE); - if (ret == null) { - ret = method.declaredAnnotation(DotNames.COMPLETE_PROMPT); - } + for (AnnotationInstance annotation : method.declaredAnnotations()) { + if (FEATURES.containsKey(annotation.name())) { + return annotation; } } - return ret; + return null; } private Feature getFeature(AnnotationInstance annotation) { - if (annotation.name().equals(DotNames.PROMPT)) { - return PROMPT; - } else if (annotation.name().equals(DotNames.COMPLETE_PROMPT)) { - return PROMPT_COMPLETE; - } else if (annotation.name().equals(DotNames.TOOL)) { - return TOOL; - } else if (annotation.name().equals(DotNames.RESOURCE)) { - return RESOURCE; + Feature ret = FEATURES.get(annotation.name()); + if (ret != null) { + return ret; } throw new IllegalStateException(); } @@ -294,6 +305,17 @@ void generateMetadata(McpServerRecorder recorder, RecorderContext recorderContex } resourcesMethod.returnValue(retResources); + // io.quarkiverse.mcp.server.runtime.McpMetadata.resourceTemplates() + MethodCreator resourceTemplatesMethod = metadataCreator.getMethodCreator("resourceTemplates", List.class); + ResultHandle retResourceTemplates = Gizmo.newArrayList(resourceTemplatesMethod); + for (FeatureMethodBuildItem resourceTemplate : featureMethods.stream() + .filter(FeatureMethodBuildItem::isResourceTemplate).toList()) { + processFeatureMethod(counter, metadataCreator, resourceTemplatesMethod, resourceTemplate, retResourceTemplates, + transformedAnnotations, + DotNames.RESOURCE_TEMPLATE_ARG); + } + resourceTemplatesMethod.returnValue(retResourceTemplates); + metadataCreator.close(); syntheticBeans.produce(SyntheticBeanBuildItem.configure(McpMetadata.class) @@ -338,6 +360,7 @@ private void validateFeatureMethod(MethodInfo method, Feature feature) { case PROMPT_COMPLETE -> validatePromptCompleteMethod(method); case TOOL -> validateToolMethod(method); case RESOURCE -> validateResourceMethod(method); + case RESOURCE_TEMPLATE -> validateResourceTemplateMethod(method); default -> throw new IllegalArgumentException("Unsupported feature: " + feature); } } @@ -406,9 +429,9 @@ private void validateToolMethod(MethodInfo method) { } } - private static final Set RESOURCE_TYPES = Set.of(ClassType.create(DotNames.RESOURCE_RESPONSE), - ClassType.create(DotNames.RESOURCE_CONTENS), ClassType.create(DotNames.TEXT_RESOURCE_CONTENS), - ClassType.create(DotNames.BLOB_RESOURCE_CONTENS)); + static final Set RESOURCE_TYPES = Set.of(ClassType.create(DotNames.RESOURCE_RESPONSE), + ClassType.create(DotNames.RESOURCE_CONTENTS), ClassType.create(DotNames.TEXT_RESOURCE_CONTENTS), + ClassType.create(DotNames.BLOB_RESOURCE_CONTENTS)); private void validateResourceMethod(MethodInfo method) { org.jboss.jandex.Type type = method.returnType(); @@ -431,11 +454,27 @@ private void validateResourceMethod(MethodInfo method) { } } + private void validateResourceTemplateMethod(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 (!RESOURCE_TYPES.contains(type)) { + throw new IllegalStateException("Unsupported Resource template method return type: " + method); + } + // TODO validate params + } + private boolean hasFeatureMethod(BeanInfo bean) { ClassInfo beanClass = bean.getTarget().get().asClass(); return beanClass.hasAnnotation(DotNames.PROMPT) + || beanClass.hasAnnotation(DotNames.COMPLETE_PROMPT) || beanClass.hasAnnotation(DotNames.TOOL) - || beanClass.hasAnnotation(DotNames.RESOURCE); + || beanClass.hasAnnotation(DotNames.RESOURCE) + || beanClass.hasAnnotation(DotNames.RESOURCE_TEMPLATE); } private void processFeatureMethod(AtomicInteger counter, ClassCreator clazz, MethodCreator method, @@ -508,104 +547,44 @@ private FeatureArgument.Provider providerFrom(org.jboss.jandex.Type type) { private ResultHandle getMapper(BytecodeCreator bytecode, org.jboss.jandex.Type returnType, Feature feature) { - // At this point the method return type is already validated + // IMPL NOTE: at this point the method return type is already validated return switch (feature) { - case PROMPT -> promptMapper(bytecode, returnType); - case PROMPT_COMPLETE -> promptCompleteMapper(bytecode, returnType); - case TOOL -> toolMapper(bytecode, returnType); - case RESOURCE -> resourceMapper(bytecode, returnType); + case PROMPT -> readResultMapper(bytecode, + createMapperField(PROMPT, returnType, DotNames.PROMPT_RESPONSE, c -> "MESSAGE")); + case PROMPT_COMPLETE -> readResultMapper(bytecode, + createMapperField(PROMPT_COMPLETE, returnType, DotNames.COMPLETE_RESPONSE, c -> "STRING")); + case TOOL -> readResultMapper(bytecode, createMapperField(TOOL, returnType, DotNames.TOOL_RESPONSE, c -> { + return isContent(c) ? "CONTENT" : "STRING"; + })); + case RESOURCE, RESOURCE_TEMPLATE -> readResultMapper(bytecode, + createMapperField(RESOURCE, returnType, DotNames.RESOURCE_RESPONSE, c -> "CONTENT")); default -> throw new IllegalArgumentException("Unsupported feature: " + feature); }; } - private ResultHandle promptMapper(BytecodeCreator bytecode, org.jboss.jandex.Type returnType) { - if (returnType.name().equals(DotNames.PROMPT_RESPONSE)) { - return resultMapper(bytecode, "TO_UNI"); - } else if (returnType.name().equals(DotNames.LIST)) { - return resultMapper(bytecode, "PROMPT_LIST_MESSAGE"); - } else if (returnType.name().equals(DotNames.UNI)) { - org.jboss.jandex.Type typeArg = returnType.asParameterizedType().arguments().get(0); - if (typeArg.name().equals(DotNames.PROMPT_RESPONSE)) { - return resultMapper(bytecode, "IDENTITY"); - } else if (typeArg.name().equals(DotNames.LIST)) { - return resultMapper(bytecode, "PROMPT_UNI_LIST_MESSAGE"); - } else { - return resultMapper(bytecode, "PROMPT_UNI_SINGLE_MESSAGE"); - } - } else { - return resultMapper(bytecode, "PROMPT_SINGLE_MESSAGE"); + static String createMapperField(FeatureMetadata.Feature feature, org.jboss.jandex.Type returnType, + DotName baseType, Function componentMapper) { + if (returnType.name().equals(baseType)) { + return "TO_UNI"; } - } + org.jboss.jandex.Type type = returnType; + StringBuilder ret = new StringBuilder(feature.toString()) + .append("_"); - private ResultHandle promptCompleteMapper(BytecodeCreator bytecode, org.jboss.jandex.Type returnType) { - if (returnType.name().equals(DotNames.COMPLETE_RESPONSE)) { - return resultMapper(bytecode, "TO_UNI"); - } else if (returnType.name().equals(DotNames.STRING)) { - return resultMapper(bytecode, "PROMPT_COMPLETE_STRING"); - } else if (returnType.name().equals(DotNames.LIST)) { - return resultMapper(bytecode, "PROMPT_COMPLETE_LIST_STRING"); - } else if (returnType.name().equals(DotNames.UNI)) { - org.jboss.jandex.Type typeArg = returnType.asParameterizedType().arguments().get(0); - if (typeArg.name().equals(DotNames.COMPLETE_RESPONSE)) { - return resultMapper(bytecode, "IDENTITY"); - } - if (returnType.name().equals(DotNames.STRING)) { - return resultMapper(bytecode, "PROMPT_COMPLETE_UNI_STRING"); - } else if (typeArg.name().equals(DotNames.LIST)) { - return resultMapper(bytecode, "PROMPT_COMPLETE_UNI_LIST_STRING"); + if (DotNames.UNI.equals(type.name())) { + type = type.asParameterizedType().arguments().get(0); + if (type.name().equals(baseType)) { + return "IDENTITY"; } + ret.append("UNI_"); } - throw new IllegalArgumentException("Unsupported return type"); - } - - private ResultHandle toolMapper(BytecodeCreator bytecode, org.jboss.jandex.Type returnType) { - if (returnType.name().equals(DotNames.TOOL_RESPONSE)) { - return resultMapper(bytecode, "TO_UNI"); - } else if (isContent(returnType.name())) { - return resultMapper(bytecode, "TOOL_CONTENT"); - } else if (returnType.name().equals(DotNames.STRING)) { - return resultMapper(bytecode, "TOOL_STRING"); - } else if (returnType.name().equals(DotNames.LIST)) { - if (returnType.asParameterizedType().arguments().get(0).name().equals(DotNames.STRING)) { - return resultMapper(bytecode, "TOOL_LIST_STRING"); - } - return resultMapper(bytecode, "TOOL_LIST_CONTENT"); - } else if (returnType.name().equals(DotNames.UNI)) { - org.jboss.jandex.Type typeArg = returnType.asParameterizedType().arguments().get(0); - if (typeArg.name().equals(DotNames.TOOL_RESPONSE)) { - return resultMapper(bytecode, "IDENTITY"); - } else if (isContent(typeArg.name())) { - return resultMapper(bytecode, "TOOL_UNI_CONTENT"); - } else if (typeArg.name().equals(DotNames.STRING)) { - return resultMapper(bytecode, "TOOL_UNI_STRING"); - } else if (typeArg.name().equals(DotNames.LIST)) { - if (typeArg.asParameterizedType().arguments().get(0).name().equals(DotNames.STRING)) { - return resultMapper(bytecode, "TOOL_UNI_LIST_STRING"); - } - return resultMapper(bytecode, "TOOL_UNI_LIST_CONTENT"); - } + if (DotNames.LIST.equals(type.name())) { + type = type.asParameterizedType().arguments().get(0); + ret.append("LIST_"); } - throw new IllegalArgumentException("Unsupported return type"); - } + ret.append(componentMapper.apply(type.name())); - private ResultHandle resourceMapper(BytecodeCreator bytecode, org.jboss.jandex.Type returnType) { - if (returnType.name().equals(DotNames.RESOURCE_RESPONSE)) { - return resultMapper(bytecode, "TO_UNI"); - } else if (isResourceContents(returnType.name())) { - return resultMapper(bytecode, "RESOURCE_CONTENT"); - } else if (returnType.name().equals(DotNames.LIST)) { - return resultMapper(bytecode, "RESOURCE_LIST_CONTENT"); - } else if (returnType.name().equals(DotNames.UNI)) { - org.jboss.jandex.Type typeArg = returnType.asParameterizedType().arguments().get(0); - if (typeArg.name().equals(DotNames.RESOURCE_RESPONSE)) { - return resultMapper(bytecode, "IDENTITY"); - } else if (isResourceContents(typeArg.name())) { - return resultMapper(bytecode, "RESOURCE_UNI_CONTENT"); - } else if (typeArg.name().equals(DotNames.LIST)) { - return resultMapper(bytecode, "RESOURCE_UNI_LIST_CONTENT"); - } - } - throw new IllegalArgumentException("Unsupported return type"); + return ret.toString(); } private boolean isContent(DotName typeName) { @@ -613,12 +592,7 @@ private boolean isContent(DotName typeName) { || DotNames.IMAGE_CONTENT.equals(typeName) || DotNames.RESOURCE_CONTENT.equals(typeName); } - private boolean isResourceContents(DotName typeName) { - return DotNames.RESOURCE_CONTENS.equals(typeName) || DotNames.TEXT_RESOURCE_CONTENS.equals(typeName) - || DotNames.BLOB_RESOURCE_CONTENS.equals(typeName); - } - - private ResultHandle resultMapper(BytecodeCreator bytecode, String contantName) { + private ResultHandle readResultMapper(BytecodeCreator bytecode, String contantName) { return bytecode.readStaticField(FieldDescriptor.of(ResultMappers.class, contantName, Function.class)); } diff --git a/core/deployment/src/test/java/io/quarkiverse/mcp/server/deployment/McpServerProcessorTest.java b/core/deployment/src/test/java/io/quarkiverse/mcp/server/deployment/McpServerProcessorTest.java new file mode 100644 index 0000000..6b986df --- /dev/null +++ b/core/deployment/src/test/java/io/quarkiverse/mcp/server/deployment/McpServerProcessorTest.java @@ -0,0 +1,26 @@ +package io.quarkiverse.mcp.server.deployment; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.jboss.jandex.ClassType; +import org.jboss.jandex.ParameterizedType; +import org.junit.jupiter.api.Test; + +import io.quarkiverse.mcp.server.runtime.FeatureMetadata.Feature; + +public class McpServerProcessorTest { + + @Test + public void testCreateMapperField() { + assertEquals("RESOURCE_CONTENT", + McpServerProcessor.createMapperField(Feature.RESOURCE, ClassType.create(DotNames.TEXT_RESOURCE_CONTENTS), + DotNames.RESOURCE_RESPONSE, + c -> "CONTENT")); + assertEquals("IDENTITY", + McpServerProcessor.createMapperField(Feature.RESOURCE, + ParameterizedType.create(DotNames.UNI, ClassType.create(DotNames.RESOURCE_RESPONSE)), + DotNames.RESOURCE_RESPONSE, + c -> "CONTENT")); + } + +} diff --git a/core/runtime/pom.xml b/core/runtime/pom.xml index 16cb794..0cc9b3a 100644 --- a/core/runtime/pom.xml +++ b/core/runtime/pom.xml @@ -25,6 +25,11 @@ jsonschema-generator ${jsonschema-generator.version} + + org.junit.jupiter + junit-jupiter + test + diff --git a/core/runtime/src/main/java/io/quarkiverse/mcp/server/ResourceTemplate.java b/core/runtime/src/main/java/io/quarkiverse/mcp/server/ResourceTemplate.java new file mode 100644 index 0000000..4deea0a --- /dev/null +++ b/core/runtime/src/main/java/io/quarkiverse/mcp/server/ResourceTemplate.java @@ -0,0 +1,57 @@ +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 as an exposed resource template. + *

+ * The result of a "resource read" operation is always represented as a {@link ResourceResponse}. However, the annotated method + * can also return other types that are converted according to the following rules. + * + *

    + *
  • If the method returns an implementation of {@link ResourceContents} then the reponse contains the single contents + * object.
  • + *
  • If the method returns a {@link List} of {@link ResourceContents} implementations then the reponse contains the list of + * contents objects.
  • + *
  • The method may return a {@link Uni} that wraps any of the type mentioned above.
  • + *
+ */ +@Retention(RUNTIME) +@Target(METHOD) +public @interface ResourceTemplate { + + /** + * Constant value for {@link #name()} indicating that the annotated element's name should be used as-is. + */ + String ELEMENT_NAME = "<>"; + + /** + * The human-readable name for this resource template. + */ + String name() default ELEMENT_NAME; + + /** + * The description of what this resource template represents. + */ + String description() default ""; + + /** + * The Level 1 URI template that can be used to construct resource URIs. + *

+ * See the RFC 6570 for syntax definition. + */ + String uriTemplate(); + + /** + * The MIME type of this resource template. + */ + String mimeType() default ""; + +} diff --git a/core/runtime/src/main/java/io/quarkiverse/mcp/server/ResourceTemplateArg.java b/core/runtime/src/main/java/io/quarkiverse/mcp/server/ResourceTemplateArg.java new file mode 100644 index 0000000..5a51034 --- /dev/null +++ b/core/runtime/src/main/java/io/quarkiverse/mcp/server/ResourceTemplateArg.java @@ -0,0 +1,23 @@ +package io.quarkiverse.mcp.server; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +/** + * Annotates a parameter of a {@link ResourceTemplate} method. + */ +@Retention(RUNTIME) +@Target(ElementType.PARAMETER) +public @interface ResourceTemplateArg { + + /** + * Constant value for {@link #name()} indicating that the annotated element's name should be used as-is. + */ + String ELEMENT_NAME = "<>"; + + String name() default ELEMENT_NAME; + +} diff --git a/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/FeatureManager.java b/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/FeatureManager.java index afb74f6..813f96a 100644 --- a/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/FeatureManager.java +++ b/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/FeatureManager.java @@ -43,7 +43,7 @@ public Future execute(String id, ArgumentProviders argProviders) throws McpEx throw notFound(id); } Invoker invoker = metadata.invoker(); - Object[] arguments = prepareArguments(metadata.info(), argProviders); + Object[] arguments = prepareArguments(metadata, argProviders); return execute(metadata.executionModel(), new Callable>() { @Override public Uni call() throws Exception { @@ -63,13 +63,13 @@ public boolean isEmpty() { } @SuppressWarnings("unchecked") - private Object[] prepareArguments(FeatureMethodInfo info, ArgumentProviders argProviders) throws McpException { - if (info.arguments().isEmpty()) { + protected Object[] prepareArguments(FeatureMetadata metadata, ArgumentProviders argProviders) throws McpException { + if (metadata.info().arguments().isEmpty()) { return new Object[0]; } - Object[] ret = new Object[info.arguments().size()]; + Object[] ret = new Object[metadata.info().arguments().size()]; int idx = 0; - for (FeatureArgument arg : info.arguments()) { + for (FeatureArgument arg : metadata.info().arguments()) { if (arg.provider() == Provider.MCP_CONNECTION) { ret[idx] = argProviders.connection(); } else if (arg.provider() == Provider.REQUEST_ID) { diff --git a/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/FeatureMetadata.java b/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/FeatureMetadata.java index 11c8c7c..42afcb7 100644 --- a/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/FeatureMetadata.java +++ b/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/FeatureMetadata.java @@ -33,6 +33,9 @@ public JsonObject asJson() { } else if (feature == Feature.RESOURCE) { json.put("uri", info.uri()) .put("mimeType", info.mimeType()); + } else if (feature == Feature.RESOURCE_TEMPLATE) { + json.put("uriTemplate", info.uri()) + .put("mimeType", info.mimeType()); } return json; } @@ -41,7 +44,12 @@ public enum Feature { PROMPT, TOOL, RESOURCE, - PROMPT_COMPLETE + RESOURCE_TEMPLATE, + PROMPT_COMPLETE; + + public boolean requiresUri() { + return this == RESOURCE || this == RESOURCE_TEMPLATE; + } } } diff --git a/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/McpMessageHandler.java b/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/McpMessageHandler.java index a830c60..3ea56fa 100644 --- a/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/McpMessageHandler.java +++ b/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/McpMessageHandler.java @@ -22,26 +22,26 @@ public class McpMessageHandler { protected final ConnectionManager connectionManager; private final ToolMessageHandler toolHandler; - private final PromptMessageHandler promptHandler; - private final PromptCompletionMessageHandler promptCompleteHandler; - private final ResourceMessageHandler resourceHandler; + private final ResourceTemplateMessageHandler resourceTemplateHandler; protected final McpRuntimeConfig config; private final Map serverInfo; protected McpMessageHandler(McpRuntimeConfig config, ConnectionManager connectionManager, PromptManager promptManager, - ToolManager toolManager, ResourceManager resourceManager, PromptCompleteManager promptCompleteManager) { + ToolManager toolManager, ResourceManager resourceManager, PromptCompleteManager promptCompleteManager, + ResourceTemplateManager resourceTemplateManager) { this.connectionManager = connectionManager; this.toolHandler = new ToolMessageHandler(toolManager); this.promptHandler = new PromptMessageHandler(promptManager); this.promptCompleteHandler = new PromptCompletionMessageHandler(promptCompleteManager); this.resourceHandler = new ResourceMessageHandler(resourceManager); + this.resourceTemplateHandler = new ResourceTemplateMessageHandler(resourceTemplateManager); this.config = config; - this.serverInfo = serverInfo(promptManager, toolManager, resourceManager); + this.serverInfo = serverInfo(promptManager, toolManager, resourceManager, resourceTemplateManager); } public void handle(JsonObject message, McpConnection connection, Responder responder) { @@ -99,6 +99,7 @@ private void initializing(JsonObject message, Responder responder, McpConnection private static final String TOOLS_LIST = "tools/list"; private static final String TOOLS_CALL = "tools/call"; private static final String RESOURCES_LIST = "resources/list"; + private static final String RESOURCE_TEMPLATES_LIST = "resources/templates/list"; private static final String RESOURCES_READ = "resources/read"; private static final String PING = "ping"; private static final String COMPLETION_COMPLETE = "completion/complete"; @@ -115,6 +116,7 @@ private void operation(JsonObject message, Responder responder, McpConnection co case PING -> ping(message, responder); case RESOURCES_LIST -> resourceHandler.resourcesList(message, responder); case RESOURCES_READ -> resourceHandler.resourcesRead(message, responder, connection); + case RESOURCE_TEMPLATES_LIST -> resourceTemplateHandler.resourceTemplatesList(message, responder); case COMPLETION_COMPLETE -> complete(message, responder, connection); case Q_CLOSE -> close(message, responder, connection); default -> responder.send( @@ -180,7 +182,7 @@ private InitializeRequest decodeInitializeRequest(JsonObject params) { } private Map serverInfo(PromptManager promptManager, ToolManager toolManager, - ResourceManager resourceManager) { + ResourceManager resourceManager, ResourceTemplateManager resourceTemplateManager) { Map info = new HashMap<>(); info.put("protocolVersion", "2024-11-05"); @@ -197,7 +199,7 @@ private Map serverInfo(PromptManager promptManager, ToolManager if (!toolManager.isEmpty()) { capabilities.put("tools", Map.of()); } - if (!resourceManager.isEmpty()) { + if (!resourceManager.isEmpty() || resourceTemplateManager.isEmpty()) { capabilities.put("resources", Map.of()); } info.put("capabilities", capabilities); diff --git a/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/McpMetadata.java b/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/McpMetadata.java index 45568a8..dc107a8 100644 --- a/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/McpMetadata.java +++ b/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/McpMetadata.java @@ -17,4 +17,6 @@ public interface McpMetadata { List> resources(); + List> resourceTemplates(); + } diff --git a/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResourceManager.java b/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResourceManager.java index 6ddfa42..b2a4c78 100644 --- a/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResourceManager.java +++ b/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResourceManager.java @@ -10,21 +10,43 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.quarkiverse.mcp.server.ResourceResponse; +import io.quarkiverse.mcp.server.runtime.FeatureMetadata.Feature; import io.vertx.core.Vertx; @Singleton public class ResourceManager extends FeatureManager { + final ResourceTemplateManager resourceTemplateManager; + final Map> resources; - ResourceManager(McpMetadata metadata, Vertx vertx, ObjectMapper mapper) { + ResourceManager(McpMetadata metadata, Vertx vertx, ObjectMapper mapper, ResourceTemplateManager resourceTemplateManager) { super(vertx, mapper); + this.resourceTemplateManager = resourceTemplateManager; this.resources = metadata.resources().stream().collect(Collectors.toMap(m -> m.info().uri(), Function.identity())); } @Override protected FeatureMetadata getMetadata(String identifier) { - return resources.get(identifier); + FeatureMetadata ret = resources.get(identifier); + if (ret == null) { + ret = resourceTemplateManager.getMetadata(identifier); + } + return ret; + } + + @Override + protected Object[] prepareArguments(FeatureMetadata metadata, ArgumentProviders argProviders) throws McpException { + if (metadata.feature() == Feature.RESOURCE_TEMPLATE) { + // Use variable matching to extract method arguments + Map matchedVariables = resourceTemplateManager.getVariableMatcher(metadata.info().name()) + .matchVariables(argProviders.args().get("uri").toString()); + matchedVariables.putIfAbsent("uri", argProviders.args().get("uri")); + argProviders = new ArgumentProviders( + matchedVariables, + argProviders.connection(), argProviders.requestId()); + } + return super.prepareArguments(metadata, argProviders); } /** diff --git a/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResourceTemplateManager.java b/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResourceTemplateManager.java new file mode 100644 index 0000000..ed8fed0 --- /dev/null +++ b/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResourceTemplateManager.java @@ -0,0 +1,93 @@ +package io.quarkiverse.mcp.server.runtime; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import jakarta.inject.Singleton; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.quarkiverse.mcp.server.ResourceResponse; +import io.vertx.core.Vertx; + +@Singleton +public class ResourceTemplateManager extends FeatureManager { + + final Map templates; + + ResourceTemplateManager(McpMetadata metadata, Vertx vertx, ObjectMapper mapper) { + super(vertx, mapper); + this.templates = metadata.resourceTemplates().stream().collect(Collectors.toMap(m -> m.info().name(), + m -> new ResourceTemplateMetadata(createMatcherFromUriTemplate(m.info().uri()), m))); + } + + VariableMatcher getVariableMatcher(String name) { + return templates.get(name).variableMatcher(); + } + + @Override + protected FeatureMetadata getMetadata(String identifier) { + // This method is used by ResourceManager during "resources/read" + // We need to iterate over all templates and find the matching URI template + for (ResourceTemplateMetadata t : templates.values()) { + if (t.variableMatcher().matches(identifier)) { + return t.metadata(); + } + } + return null; + } + + /** + * + * @return the list of resource templates sorted by name asc + */ + public List> list() { + return templates.values().stream().map(ResourceTemplateMetadata::metadata).sorted().toList(); + } + + @Override + protected McpException notFound(String id) { + return new McpException("Invalid resource uri: " + id, JsonRPC.RESOURCE_NOT_FOUND); + } + + static VariableMatcher createMatcherFromUriTemplate(String uriTemplate) { + // Find variables + List variables = new ArrayList<>(); + Matcher m = Pattern.compile("\\{(\\w+)\\}").matcher(uriTemplate); + StringBuilder uriRegex = new StringBuilder(); + while (m.find()) { + variables.add(m.group(1)); + m.appendReplacement(uriRegex, "([^/]+)"); + } + m.appendTail(uriRegex); + return new VariableMatcher(Pattern.compile(uriRegex.toString()), variables); + } + + record ResourceTemplateMetadata(VariableMatcher variableMatcher, FeatureMetadata metadata) { + } + + record VariableMatcher(Pattern pattern, List variables) { + + boolean matches(String uri) { + return pattern.matcher(uri).matches(); + } + + Map matchVariables(String uri) { + Map ret = new HashMap<>(); + Matcher m = pattern.matcher(uri); + if (m.matches()) { + for (int i = 0; i < m.groupCount(); i++) { + ret.put(variables.get(i), m.group(i + 1)); + } + } + return ret; + } + + } + +} diff --git a/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResourceTemplateMessageHandler.java b/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResourceTemplateMessageHandler.java new file mode 100644 index 0000000..206f673 --- /dev/null +++ b/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResourceTemplateMessageHandler.java @@ -0,0 +1,29 @@ +package io.quarkiverse.mcp.server.runtime; + +import org.jboss.logging.Logger; + +import io.quarkiverse.mcp.server.ResourceResponse; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; + +class ResourceTemplateMessageHandler { + + private static final Logger LOG = Logger.getLogger(ResourceTemplateMessageHandler.class); + + private final ResourceTemplateManager manager; + + ResourceTemplateMessageHandler(ResourceTemplateManager manager) { + this.manager = manager; + } + + void resourceTemplatesList(JsonObject message, Responder responder) { + Object id = message.getValue("id"); + LOG.infof("List resource templates [id: %s]", id); + JsonArray resources = new JsonArray(); + for (FeatureMetadata resource : manager.list()) { + resources.add(resource.asJson()); + } + responder.sendResult(id, new JsonObject().put("resourceTemplates", resources)); + } + +} diff --git a/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResultMappers.java b/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResultMappers.java index 9ec0bd8..1146e66 100644 --- a/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResultMappers.java +++ b/core/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResultMappers.java @@ -15,13 +15,13 @@ public class ResultMappers { - public static final Function> PROMPT_SINGLE_MESSAGE = message -> Uni.createFrom() + public static final Function> PROMPT_MESSAGE = message -> Uni.createFrom() .item(new PromptResponse(null, List.of(message))); public static final Function, Uni> PROMPT_LIST_MESSAGE = messages -> Uni.createFrom() .item(PromptResponse.withMessages(messages)); - public static final Function, Uni> PROMPT_UNI_SINGLE_MESSAGE = uni -> { + public static final Function, Uni> PROMPT_UNI_MESSAGE = uni -> { return uni.map(m -> new PromptResponse(null, List.of(m))); }; diff --git a/core/runtime/src/test/java/io/quarkiverse/mcp/server/runtime/ResourceTemplateManagerTest.java b/core/runtime/src/test/java/io/quarkiverse/mcp/server/runtime/ResourceTemplateManagerTest.java new file mode 100644 index 0000000..d70ca4c --- /dev/null +++ b/core/runtime/src/test/java/io/quarkiverse/mcp/server/runtime/ResourceTemplateManagerTest.java @@ -0,0 +1,29 @@ +package io.quarkiverse.mcp.server.runtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import io.quarkiverse.mcp.server.runtime.ResourceTemplateManager.VariableMatcher; + +public class ResourceTemplateManagerTest { + + @Test + public void testCreateMatcherFromUriTemplate() { + assertVariableMatcher("file:///{foo}", "file:///bar", Map.of("foo", "bar")); + assertVariableMatcher("file:///{foo}/{bar}/baz", "file:///alpha/bravo/baz", Map.of("foo", "alpha", "bar", "bravo")); + + } + + private void assertVariableMatcher(String uriTemplate, String uri, Map expectedVars) { + VariableMatcher matcher = ResourceTemplateManager.createMatcherFromUriTemplate(uriTemplate); + assertTrue(expectedVars.keySet().containsAll(matcher.variables())); + assertTrue(matcher.pattern().matcher(uri).matches()); + Map matchedVars = matcher.matchVariables(uri); + assertEquals(expectedVars, matchedVars); + } + +} diff --git a/docs/modules/ROOT/pages/index.adoc b/docs/modules/ROOT/pages/index.adoc index 1b05aae..add757b 100644 --- a/docs/modules/ROOT/pages/index.adoc +++ b/docs/modules/ROOT/pages/index.adoc @@ -198,6 +198,42 @@ A `@Resource` method may accept the following parameters: * `McpConnection` * `RequestId` +==== Resource templates + +You can also use https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#resource-templates[resource templates] to expose parameterized resources. + +[source,java] +---- +import io.quarkiverse.mcp.server.ResourceTemplate; +import jakarta.inject.Inject; +import java.nio.file.Files; + +// @Singleton <1> +public class MyResourceTemplates { + + @Inject <2> + ProjectService projectService; + + @ResourceTemplate(uriTemplate = "file:///project/{name}") <3> <4> + TextResourceContents project(String name, String uri) { <5> + return TextResourceContents.create(uri, projectService.readProject(name))); + } + +} +---- +<1> The `@Singleton` scope is added automatically, if needed. +<2> `MyResourceTemplates` is an ordinary CDI bean. It can inject other beans, use interceptors, etc. +<3> `@ResourceTemplate` annotates a business method of a CDI bean that should be exposed as a resource template. By default, the name of the resource template is derived from the method name. +<4> `ResourceTemplate#uriTemplate()` contains a Level 1 URI template (https://datatracker.ietf.org/doc/html/rfc6570#section-1.2[RFC 6570]) that can be used to construct resource URIs. +<5> The `String uri` parameter refers to the actual resource URI. + +The result of a "resource read" operation is always represented as a `ResourceResponse`. +However, the annotated method can also return other types that are converted according to the following rules. + +* If the method returns an implementation of `ResourceContents` then the reponse contains the single contents object. +* If the method returns a `List` of `ResourceContents` implementations then the reponse contains the list of contents objects. +* The method may return a `Uni` that wraps any of the type mentioned above. + === Tools MCP provides a https://spec.modelcontextprotocol.io/specification/server/tools/[standardized way] for servers to expose tools that can be invoked by clients. diff --git a/transports/sse/deployment/src/test/java/io/quarkiverse/mcp/server/test/resources/templates/MyTemplates.java b/transports/sse/deployment/src/test/java/io/quarkiverse/mcp/server/test/resources/templates/MyTemplates.java new file mode 100644 index 0000000..1b92e17 --- /dev/null +++ b/transports/sse/deployment/src/test/java/io/quarkiverse/mcp/server/test/resources/templates/MyTemplates.java @@ -0,0 +1,32 @@ +package io.quarkiverse.mcp.server.test.resources.templates; + +import static io.quarkiverse.mcp.server.test.Checks.checkDuplicatedContext; +import static io.quarkiverse.mcp.server.test.Checks.checkExecutionModel; +import static io.quarkiverse.mcp.server.test.Checks.checkRequestContext; + +import java.util.List; + +import io.quarkiverse.mcp.server.ResourceResponse; +import io.quarkiverse.mcp.server.ResourceTemplate; +import io.quarkiverse.mcp.server.TextResourceContents; + +public class MyTemplates { + + @ResourceTemplate(uriTemplate = "file:///{path}") + ResourceResponse alpha(String path) { + String uri = "file:///" + path; + checkExecutionModel(true); + checkDuplicatedContext(); + checkRequestContext(); + return new ResourceResponse(List.of(TextResourceContents.create(uri, "foo:" + path))); + } + + @ResourceTemplate(uriTemplate = "file:///{foo}/{bar}") + TextResourceContents bravo(String foo, String bar, String uri) { + checkExecutionModel(true); + checkDuplicatedContext(); + checkRequestContext(); + return TextResourceContents.create(uri, foo + ":" + bar); + } + +} diff --git a/transports/sse/deployment/src/test/java/io/quarkiverse/mcp/server/test/resources/templates/ResourceTemplatesTest.java b/transports/sse/deployment/src/test/java/io/quarkiverse/mcp/server/test/resources/templates/ResourceTemplatesTest.java new file mode 100644 index 0000000..9623997 --- /dev/null +++ b/transports/sse/deployment/src/test/java/io/quarkiverse/mcp/server/test/resources/templates/ResourceTemplatesTest.java @@ -0,0 +1,75 @@ +package io.quarkiverse.mcp.server.test.resources.templates; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.net.URI; +import java.net.URISyntaxException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkiverse.mcp.server.test.Checks; +import io.quarkiverse.mcp.server.test.McpServerTest; +import io.quarkus.test.QuarkusUnitTest; +import io.restassured.http.ContentType; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; + +public class ResourceTemplatesTest extends McpServerTest { + + @RegisterExtension + static final QuarkusUnitTest config = defaultConfig() + .withApplicationRoot( + root -> root.addClasses(MyTemplates.class, Checks.class)); + + @Test + public void testResourceTemplates() throws URISyntaxException { + URI endpoint = initClient(); + + JsonObject resourceTemplatesListMessage = newMessage("resources/templates/list"); + + given().contentType(ContentType.JSON) + .when() + .body(resourceTemplatesListMessage.encode()) + .post(endpoint) + .then() + .statusCode(200); + + JsonObject resourceTemplatesListResponse = waitForLastJsonMessage(); + + JsonObject resourceTemplatesListResult = assertResponseMessage(resourceTemplatesListMessage, + resourceTemplatesListResponse); + assertNotNull(resourceTemplatesListResult); + JsonArray resourceTemplates = resourceTemplatesListResult.getJsonArray("resourceTemplates"); + assertEquals(2, resourceTemplates.size()); + + assertResourceRead("foo:bar", "file:///bar", endpoint); + assertResourceRead("bar:baz", "file:///bar/baz", endpoint); + } + + private void assertResourceRead(String expectedText, String uri, URI endpoint) { + JsonObject resourceReadMessage = newMessage("resources/read") + .put("params", new JsonObject() + .put("uri", uri)); + + given().contentType(ContentType.JSON) + .when() + .body(resourceReadMessage.encode()) + .post(endpoint) + .then() + .statusCode(200); + + JsonObject resourceReadResponse = waitForLastJsonMessage(); + + JsonObject resourceReadResult = assertResponseMessage(resourceReadMessage, resourceReadResponse); + assertNotNull(resourceReadResult); + JsonArray contents = resourceReadResult.getJsonArray("contents"); + assertEquals(1, contents.size()); + JsonObject textContent = contents.getJsonObject(0); + assertEquals(expectedText, textContent.getString("text")); + assertEquals(uri, textContent.getString("uri")); + } + +} diff --git a/transports/sse/runtime/src/main/java/io/quarkiverse/mcp/server/sse/runtime/SseMcpMessageHandler.java b/transports/sse/runtime/src/main/java/io/quarkiverse/mcp/server/sse/runtime/SseMcpMessageHandler.java index 7d24675..5940173 100644 --- a/transports/sse/runtime/src/main/java/io/quarkiverse/mcp/server/sse/runtime/SseMcpMessageHandler.java +++ b/transports/sse/runtime/src/main/java/io/quarkiverse/mcp/server/sse/runtime/SseMcpMessageHandler.java @@ -9,6 +9,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.ResourceTemplateManager; import io.quarkiverse.mcp.server.runtime.Responder; import io.quarkiverse.mcp.server.runtime.ToolManager; import io.quarkiverse.mcp.server.runtime.TrafficLogger; @@ -27,8 +28,10 @@ class SseMcpMessageHandler extends McpMessageHandler implements Handler createMessagesEndpointHandler() { ArcContainer container = Arc.container(); return new SseMcpMessageHandler(config, container.instance(ConnectionManager.class).get(), container.instance(PromptManager.class).get(), container.instance(ToolManager.class).get(), - container.instance(ResourceManager.class).get(), container.instance(PromptCompleteManager.class).get()); + container.instance(ResourceManager.class).get(), container.instance(PromptCompleteManager.class).get(), + container.instance(ResourceTemplateManager.class).get()); } } diff --git a/transports/stdio/runtime/src/main/java/io/quarkiverse/mcp/server/stdio/runtime/StdioMcpMessageHandler.java b/transports/stdio/runtime/src/main/java/io/quarkiverse/mcp/server/stdio/runtime/StdioMcpMessageHandler.java index 2a2ef5b..9feafea 100644 --- a/transports/stdio/runtime/src/main/java/io/quarkiverse/mcp/server/stdio/runtime/StdioMcpMessageHandler.java +++ b/transports/stdio/runtime/src/main/java/io/quarkiverse/mcp/server/stdio/runtime/StdioMcpMessageHandler.java @@ -18,6 +18,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.ResourceTemplateManager; import io.quarkiverse.mcp.server.runtime.Responder; import io.quarkiverse.mcp.server.runtime.ToolManager; import io.quarkiverse.mcp.server.runtime.TrafficLogger; @@ -34,8 +35,10 @@ class StdioMcpMessageHandler extends McpMessageHandler { private final TrafficLogger trafficLogger; protected StdioMcpMessageHandler(McpRuntimeConfig config, ConnectionManager connectionManager, PromptManager promptManager, - ToolManager toolManager, ResourceManager resourceManager, PromptCompleteManager promptCompleteManager) { - super(config, connectionManager, promptManager, toolManager, resourceManager, promptCompleteManager); + ToolManager toolManager, ResourceManager resourceManager, PromptCompleteManager promptCompleteManager, + ResourceTemplateManager resourceTemplateManager) { + super(config, connectionManager, promptManager, toolManager, resourceManager, promptCompleteManager, + resourceTemplateManager); this.executor = Executors.newSingleThreadExecutor(); this.trafficLogger = config.trafficLogging().enabled() ? new TrafficLogger(config.trafficLogging().textLimit()) : null; diff --git a/transports/stdio/runtime/src/main/java/io/quarkiverse/mcp/server/stdio/runtime/StdioMcpServerRecorder.java b/transports/stdio/runtime/src/main/java/io/quarkiverse/mcp/server/stdio/runtime/StdioMcpServerRecorder.java index 5e96eb9..fe31bc9 100644 --- a/transports/stdio/runtime/src/main/java/io/quarkiverse/mcp/server/stdio/runtime/StdioMcpServerRecorder.java +++ b/transports/stdio/runtime/src/main/java/io/quarkiverse/mcp/server/stdio/runtime/StdioMcpServerRecorder.java @@ -7,6 +7,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.ResourceTemplateManager; import io.quarkiverse.mcp.server.runtime.ToolManager; import io.quarkiverse.mcp.server.runtime.config.McpRuntimeConfig; import io.quarkiverse.mcp.server.stdio.runtime.config.McpStdioRuntimeConfig; @@ -36,7 +37,8 @@ public void initialize() { StdioMcpMessageHandler messageHandler = new StdioMcpMessageHandler(config, container.instance(ConnectionManager.class).get(), container.instance(PromptManager.class).get(), container.instance(ToolManager.class).get(), - container.instance(ResourceManager.class).get(), container.instance(PromptCompleteManager.class).get()); + container.instance(ResourceManager.class).get(), container.instance(PromptCompleteManager.class).get(), + container.instance(ResourceTemplateManager.class).get()); messageHandler.initialize(stdout); }