From 8ee8121f928c2552bd996cdc54954653acea852b Mon Sep 17 00:00:00 2001 From: Martin Kouba Date: Wed, 18 Dec 2024 13:39:58 +0100 Subject: [PATCH] Implement the resources server feature - Resource Templates are not supported yet --- .../mcp/server/deployment/DotNames.java | 11 + .../deployment/FeatureMethodBuildItem.java | 32 ++- .../server/deployment/McpServerProcessor.java | 215 ++++++++++++++---- .../mcp/server/test/McpServerTest.java | 10 + .../mcp/server/test/close/CloseTest.java | 2 +- .../mcp/server/test/ping/PingTest.java | 2 +- .../mcp/server/test/prompts/PromptsTest.java | 2 +- .../server/test/resources/MyResources.java | 47 ++++ .../server/test/resources/ResourcesTest.java | 94 ++++++++ .../test/serverinfo/CustomServerInfoTest.java | 6 +- .../serverinfo/DefaultServerInfoTest.java | 2 +- .../mcp/server/test/tools/ToolsTest.java | 2 +- .../validation/DuplicateResourceUriTest.java | 40 ++++ .../validation/ResourceInvalidParamTest.java | 35 +++ .../pages/includes/quarkus-mcp-server.adoc | 40 +++- .../includes/quarkus-mcp-server_quarkus.adoc | 62 ++++- .../quarkus-mcp-server_quarkus.mcp.adoc | 100 ++++++++ .../mcp/server/BlobResourceContents.java | 36 +++ .../io/quarkiverse/mcp/server/Resource.java | 56 +++++ .../mcp/server/ResourceContent.java | 8 + .../mcp/server/ResourceContents.java | 16 ++ .../mcp/server/ResourceResponse.java | 11 + .../mcp/server/TextResourceContents.java | 30 +++ .../java/io/quarkiverse/mcp/server/Tool.java | 4 +- .../mcp/server/runtime/FeatureArgument.java | 14 +- .../mcp/server/runtime/FeatureManager.java | 8 +- .../mcp/server/runtime/FeatureMetadata.java | 34 ++- .../mcp/server/runtime/FeatureMethodInfo.java | 6 +- .../server/runtime/McpMessagesHandler.java | 155 +++++++++---- .../mcp/server/runtime/McpMetadata.java | 3 + .../mcp/server/runtime/McpServerRecorder.java | 10 +- .../mcp/server/runtime/PromptManager.java | 10 +- .../server/runtime/PromptMessageHandler.java | 36 +-- .../mcp/server/runtime/ResourceManager.java | 38 ++++ .../runtime/ResourceMessageHandler.java | 64 ++++++ .../mcp/server/runtime/ResultMappers.java | 18 +- .../mcp/server/runtime/ToolManager.java | 10 +- .../server/runtime/ToolMessageHandler.java | 45 ++-- .../mcp/server/runtime/TrafficLogger.java | 40 ++++ .../runtime/config/McpBuildTimeConfig.java | 20 ++ .../McpRuntimeConfig.java} | 37 ++- 41 files changed, 1204 insertions(+), 207 deletions(-) create mode 100644 deployment/src/test/java/io/quarkiverse/mcp/server/test/resources/MyResources.java create mode 100644 deployment/src/test/java/io/quarkiverse/mcp/server/test/resources/ResourcesTest.java create mode 100644 deployment/src/test/java/io/quarkiverse/mcp/server/test/validation/DuplicateResourceUriTest.java create mode 100644 deployment/src/test/java/io/quarkiverse/mcp/server/test/validation/ResourceInvalidParamTest.java create mode 100644 docs/modules/ROOT/pages/includes/quarkus-mcp-server_quarkus.mcp.adoc create mode 100644 runtime/src/main/java/io/quarkiverse/mcp/server/BlobResourceContents.java create mode 100644 runtime/src/main/java/io/quarkiverse/mcp/server/Resource.java create mode 100644 runtime/src/main/java/io/quarkiverse/mcp/server/ResourceContents.java create mode 100644 runtime/src/main/java/io/quarkiverse/mcp/server/ResourceResponse.java create mode 100644 runtime/src/main/java/io/quarkiverse/mcp/server/TextResourceContents.java create mode 100644 runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResourceManager.java create mode 100644 runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResourceMessageHandler.java create mode 100644 runtime/src/main/java/io/quarkiverse/mcp/server/runtime/TrafficLogger.java create mode 100644 runtime/src/main/java/io/quarkiverse/mcp/server/runtime/config/McpBuildTimeConfig.java rename runtime/src/main/java/io/quarkiverse/mcp/server/runtime/{McpBuildTimeConfig.java => config/McpRuntimeConfig.java} (64%) diff --git a/deployment/src/main/java/io/quarkiverse/mcp/server/deployment/DotNames.java b/deployment/src/main/java/io/quarkiverse/mcp/server/deployment/DotNames.java index 17ddf4f..6aa5b8a 100644 --- a/deployment/src/main/java/io/quarkiverse/mcp/server/deployment/DotNames.java +++ b/deployment/src/main/java/io/quarkiverse/mcp/server/deployment/DotNames.java @@ -4,6 +4,7 @@ import org.jboss.jandex.DotName; +import io.quarkiverse.mcp.server.BlobResourceContents; import io.quarkiverse.mcp.server.Content; import io.quarkiverse.mcp.server.ImageContent; import io.quarkiverse.mcp.server.McpConnection; @@ -12,8 +13,12 @@ import io.quarkiverse.mcp.server.PromptMessage; import io.quarkiverse.mcp.server.PromptResponse; import io.quarkiverse.mcp.server.RequestId; +import io.quarkiverse.mcp.server.Resource; import io.quarkiverse.mcp.server.ResourceContent; +import io.quarkiverse.mcp.server.ResourceContents; +import io.quarkiverse.mcp.server.ResourceResponse; import io.quarkiverse.mcp.server.TextContent; +import io.quarkiverse.mcp.server.TextResourceContents; import io.quarkiverse.mcp.server.Tool; import io.quarkiverse.mcp.server.ToolArg; import io.quarkiverse.mcp.server.ToolResponse; @@ -45,5 +50,11 @@ class DotNames { static final DotName TEXT_CONTENT = DotName.createSimple(TextContent.class); static final DotName IMAGE_CONTENT = DotName.createSimple(ImageContent.class); 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 STRING = DotName.createSimple(String.class); } diff --git a/deployment/src/main/java/io/quarkiverse/mcp/server/deployment/FeatureMethodBuildItem.java b/deployment/src/main/java/io/quarkiverse/mcp/server/deployment/FeatureMethodBuildItem.java index ada9d23..fd70fad 100644 --- a/deployment/src/main/java/io/quarkiverse/mcp/server/deployment/FeatureMethodBuildItem.java +++ b/deployment/src/main/java/io/quarkiverse/mcp/server/deployment/FeatureMethodBuildItem.java @@ -4,6 +4,8 @@ import org.jboss.jandex.MethodInfo; +import io.quarkiverse.mcp.server.runtime.FeatureMetadata; +import io.quarkiverse.mcp.server.runtime.FeatureMetadata.Feature; import io.quarkus.arc.processor.BeanInfo; import io.quarkus.arc.processor.InvokerInfo; import io.quarkus.builder.item.MultiBuildItem; @@ -14,17 +16,24 @@ final class FeatureMethodBuildItem extends MultiBuildItem { private final InvokerInfo invoker; private final String name; private final String description; + private final MethodInfo method; private final Feature feature; - FeatureMethodBuildItem(BeanInfo bean, MethodInfo method, InvokerInfo invoker, String name, String description, - Feature feature) { + // Resource-only + private final String uri; + private final String mimeType; + + FeatureMethodBuildItem(BeanInfo bean, MethodInfo method, InvokerInfo invoker, String name, String description, String uri, + String mimeType, FeatureMetadata.Feature feature) { this.bean = Objects.requireNonNull(bean); this.method = Objects.requireNonNull(method); this.invoker = Objects.requireNonNull(invoker); + this.feature = Objects.requireNonNull(feature); this.name = Objects.requireNonNull(name); this.description = description; - this.feature = Objects.requireNonNull(feature); + this.uri = feature == Feature.RESOURCE ? Objects.requireNonNull(uri) : null; + this.mimeType = mimeType; } BeanInfo getBean() { @@ -47,6 +56,14 @@ String getDescription() { return description; } + String getUri() { + return uri; + } + + String getMimeType() { + return mimeType; + } + Feature getFeature() { return feature; } @@ -59,15 +76,14 @@ boolean isPrompt() { return feature == Feature.PROMPT; } + boolean isResource() { + return feature == Feature.RESOURCE; + } + @Override public String toString() { return "FeatureMethodBuildItem [name=" + name + ", method=" + method.declaringClass() + "#" + method.name() + "(), feature=" + feature + "]"; } - enum Feature { - PROMPT, - TOOL - } - } diff --git a/deployment/src/main/java/io/quarkiverse/mcp/server/deployment/McpServerProcessor.java b/deployment/src/main/java/io/quarkiverse/mcp/server/deployment/McpServerProcessor.java index 166f62e..f940e21 100644 --- a/deployment/src/main/java/io/quarkiverse/mcp/server/deployment/McpServerProcessor.java +++ b/deployment/src/main/java/io/quarkiverse/mcp/server/deployment/McpServerProcessor.java @@ -1,7 +1,8 @@ package io.quarkiverse.mcp.server.deployment; -import static io.quarkiverse.mcp.server.deployment.FeatureMethodBuildItem.Feature.PROMPT; -import static io.quarkiverse.mcp.server.deployment.FeatureMethodBuildItem.Feature.TOOL; +import static io.quarkiverse.mcp.server.runtime.FeatureMetadata.Feature.PROMPT; +import static io.quarkiverse.mcp.server.runtime.FeatureMetadata.Feature.RESOURCE; +import static io.quarkiverse.mcp.server.runtime.FeatureMetadata.Feature.TOOL; import static io.quarkus.deployment.annotations.ExecutionTime.RUNTIME_INIT; import java.lang.reflect.Modifier; @@ -11,6 +12,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; import java.util.stream.Collectors; @@ -33,22 +35,24 @@ import io.quarkiverse.mcp.server.ResourceContent; import io.quarkiverse.mcp.server.TextContent; import io.quarkiverse.mcp.server.ToolResponse; -import io.quarkiverse.mcp.server.deployment.FeatureMethodBuildItem.Feature; import io.quarkiverse.mcp.server.runtime.ExecutionModel; import io.quarkiverse.mcp.server.runtime.FeatureArgument; import io.quarkiverse.mcp.server.runtime.FeatureMetadata; +import io.quarkiverse.mcp.server.runtime.FeatureMetadata.Feature; import io.quarkiverse.mcp.server.runtime.FeatureMethodInfo; -import io.quarkiverse.mcp.server.runtime.McpBuildTimeConfig; import io.quarkiverse.mcp.server.runtime.McpMetadata; import io.quarkiverse.mcp.server.runtime.McpServerRecorder; import io.quarkiverse.mcp.server.runtime.PromptManager; +import io.quarkiverse.mcp.server.runtime.ResourceManager; import io.quarkiverse.mcp.server.runtime.ResultMappers; import io.quarkiverse.mcp.server.runtime.ToolManager; +import io.quarkiverse.mcp.server.runtime.config.McpBuildTimeConfig; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.deployment.AutoAddScopeBuildItem; import io.quarkus.arc.deployment.BeanDiscoveryFinishedBuildItem; import io.quarkus.arc.deployment.InvokerFactoryBuildItem; import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.arc.deployment.SyntheticBeansRuntimeInitBuildItem; import io.quarkus.arc.deployment.TransformedAnnotationsBuildItem; import io.quarkus.arc.deployment.ValidationPhaseBuildItem.ValidationErrorBuildItem; import io.quarkus.arc.processor.BeanInfo; @@ -59,6 +63,7 @@ import io.quarkus.deployment.GeneratedClassGizmoAdaptor; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.Consume; import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.FeatureBuildItem; import io.quarkus.deployment.builditem.GeneratedClassBuildItem; @@ -88,18 +93,19 @@ FeatureBuildItem feature() { @BuildStep void addBeans(BuildProducer additionalBeans) { additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf("io.quarkiverse.mcp.server.runtime.ConnectionManager")); - additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf(PromptManager.class)); - additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf(ToolManager.class)); + additionalBeans.produce(AdditionalBeanBuildItem.builder().setUnremovable() + .addBeanClasses(PromptManager.class, ToolManager.class, ResourceManager.class).build()); } @BuildStep AutoAddScopeBuildItem autoAddScope() { - return AutoAddScopeBuildItem.builder().containsAnnotations(DotNames.PROMPT, DotNames.TOOL) + return AutoAddScopeBuildItem.builder().containsAnnotations(DotNames.PROMPT, DotNames.TOOL, DotNames.RESOURCE) .defaultScope(BuiltinScope.SINGLETON) .build(); } @Record(RUNTIME_INIT) + @Consume(SyntheticBeansRuntimeInitBuildItem.class) @BuildStep void registerEndpoints(McpBuildTimeConfig config, HttpRootPathBuildItem rootPath, McpServerRecorder recorder, BodyHandlerBuildItem bodyHandler, @@ -127,12 +133,22 @@ void collectFeatureMethods(BeanDiscoveryFinishedBuildItem beanDiscovery, Invoker for (BeanInfo bean : beanDiscovery.beanStream().classBeans().filter(this::hasFeatureMethod)) { ClassInfo beanClass = bean.getTarget().get().asClass(); for (MethodInfo method : beanClass.methods()) { + Feature feature = null; AnnotationInstance featureAnnotation = method.declaredAnnotation(DotNames.PROMPT); - if (featureAnnotation == null) { + if (featureAnnotation != null) { + feature = PROMPT; + } else { featureAnnotation = method.declaredAnnotation(DotNames.TOOL); + if (featureAnnotation != null) { + feature = TOOL; + } else { + featureAnnotation = method.declaredAnnotation(DotNames.RESOURCE); + if (featureAnnotation != null) { + feature = RESOURCE; + } + } } if (featureAnnotation != null) { - Feature feature = featureAnnotation.name().equals(DotNames.PROMPT) ? PROMPT : TOOL; validateFeatureMethod(method, feature); AnnotationValue nameValue = featureAnnotation.value("name"); String name = nameValue != null ? nameValue.asString() : method.name(); @@ -140,8 +156,12 @@ void collectFeatureMethods(BeanDiscoveryFinishedBuildItem beanDiscovery, Invoker 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; FeatureMethodBuildItem fm = new FeatureMethodBuildItem(bean, method, invokerBuilder.build(), name, - description, feature); + description, uri, mimeType, feature); features.produce(fm); found.compute(feature, (f, list) -> { if (list == null) { @@ -171,6 +191,25 @@ void collectFeatureMethods(BeanDiscoveryFinishedBuildItem beanDiscovery, Invoker } } } + + // Check duplicate uris for resources + List resources = found.get(RESOURCE); + if (resources != null) { + Map> byUri = resources.stream() + .collect(Collectors.toMap(FeatureMethodBuildItem::getUri, List::of, (v1, v2) -> { + List list = new ArrayList<>(); + list.addAll(v1); + list.addAll(v2); + return list; + })); + for (List list : byUri.values()) { + if (list.size() > 1) { + String message = "Duplicate resource uri found:\n\t%s" + .formatted(list.stream().map(Object::toString).collect(Collectors.joining("\n\t"))); + errors.produce(new ValidationErrorBuildItem(new IllegalStateException(message))); + } + } + } } @Record(RUNTIME_INIT) @@ -190,11 +229,14 @@ void generateMetadata(McpServerRecorder recorder, RecorderContext recorderContex .interfaces(McpMetadata.class) .build(); + AtomicInteger counter = new AtomicInteger(); + // io.quarkiverse.mcp.server.runtime.McpMetadata.prompts() MethodCreator promptsMethod = metadataCreator.getMethodCreator("prompts", List.class); ResultHandle retPrompts = Gizmo.newArrayList(promptsMethod); for (FeatureMethodBuildItem prompt : featureMethods.stream().filter(FeatureMethodBuildItem::isPrompt).toList()) { - processFeatureMethod(promptsMethod, prompt, retPrompts, transformedAnnotations, DotNames.PROMPT_ARG); + processFeatureMethod(counter, metadataCreator, promptsMethod, prompt, retPrompts, transformedAnnotations, + DotNames.PROMPT_ARG); } promptsMethod.returnValue(retPrompts); @@ -202,10 +244,20 @@ void generateMetadata(McpServerRecorder recorder, RecorderContext recorderContex MethodCreator toolsMethod = metadataCreator.getMethodCreator("tools", List.class); ResultHandle retTools = Gizmo.newArrayList(toolsMethod); for (FeatureMethodBuildItem tool : featureMethods.stream().filter(FeatureMethodBuildItem::isTool).toList()) { - processFeatureMethod(toolsMethod, tool, retTools, transformedAnnotations, DotNames.TOOL_ARG); + processFeatureMethod(counter, metadataCreator, toolsMethod, tool, retTools, transformedAnnotations, + DotNames.TOOL_ARG); } toolsMethod.returnValue(retTools); + // io.quarkiverse.mcp.server.runtime.McpMetadata.resources() + MethodCreator resourcesMethod = metadataCreator.getMethodCreator("resources", List.class); + ResultHandle retResources = Gizmo.newArrayList(resourcesMethod); + for (FeatureMethodBuildItem resource : featureMethods.stream().filter(FeatureMethodBuildItem::isResource).toList()) { + processFeatureMethod(counter, metadataCreator, resourcesMethod, resource, retResources, transformedAnnotations, + null); + } + resourcesMethod.returnValue(retResources); + metadataCreator.close(); syntheticBeans.produce(SyntheticBeanBuildItem.configure(McpMetadata.class) @@ -245,6 +297,7 @@ private void validateFeatureMethod(MethodInfo method, Feature feature) { switch (feature) { case PROMPT -> validatePromptMethod(method); case TOOL -> validateToolMethod(method); + case RESOURCE -> validateResourceMethod(method); default -> throw new IllegalArgumentException("Unsupported feature: " + feature); } } @@ -261,7 +314,7 @@ private void validatePromptMethod(MethodInfo method) { type = type.asParameterizedType().arguments().get(0); } if (!PROMPT_TYPES.contains(type)) { - throw new IllegalStateException("Unsupported prompt method return type: " + method.returnType()); + throw new IllegalStateException("Unsupported prompt method return type: " + method); } } @@ -278,73 +331,114 @@ private void validateToolMethod(MethodInfo method) { type = type.asParameterizedType().arguments().get(0); } if (!TOOL_TYPES.contains(type)) { - throw new IllegalStateException("Unsupported Tool method return type: " + method.returnType()); + throw new IllegalStateException("Unsupported Tool method return type: " + 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)); + + private void validateResourceMethod(MethodInfo method) { + org.jboss.jandex.Type type = method.returnType(); + if (DotNames.UNI.equals(type.name())) { + type = type.asParameterizedType().arguments().get(0); + } + if (DotNames.LIST.equals(type.name())) { + type = type.asParameterizedType().arguments().get(0); + } + if (!RESOURCE_TYPES.contains(type)) { + throw new IllegalStateException("Unsupported Resource method return type: " + method); + } + if (method.parametersCount() > 1 + || (method.parametersCount() == 1 + && !method.parameterName(0).equals("uri") + && !method.parameterType(0).name().equals(DotNames.STRING))) { + throw new IllegalStateException( + "Resource method may accept zero paramateres or a single parameter of name 'uri' and type String: " + + method); } } private boolean hasFeatureMethod(BeanInfo bean) { ClassInfo beanClass = bean.getTarget().get().asClass(); - return beanClass.hasAnnotation(DotNames.PROMPT) || beanClass.hasAnnotation(DotNames.TOOL); + return beanClass.hasAnnotation(DotNames.PROMPT) + || beanClass.hasAnnotation(DotNames.TOOL) + || beanClass.hasAnnotation(DotNames.RESOURCE); } - private void processFeatureMethod(MethodCreator method, FeatureMethodBuildItem featureMethod, ResultHandle retList, + private void processFeatureMethod(AtomicInteger counter, ClassCreator clazz, MethodCreator method, + FeatureMethodBuildItem featureMethod, ResultHandle retList, TransformedAnnotationsBuildItem transformedAnnotations, DotName argAnnotationName) { - ResultHandle args = Gizmo.newArrayList(method); + String methodName = "meta$" + counter.incrementAndGet(); + MethodCreator metaMethod = clazz.getMethodCreator(methodName, FeatureMetadata.class); + + ResultHandle args = Gizmo.newArrayList(metaMethod); for (MethodParameterInfo pi : featureMethod.getMethod().parameters()) { String name = pi.name(); String description = ""; boolean required = true; - AnnotationInstance argAnnotation = pi.declaredAnnotation(argAnnotationName); - if (argAnnotation != null) { - AnnotationValue nameValue = argAnnotation.value("name"); - if (nameValue != null) { - name = nameValue.asString(); - } - AnnotationValue descriptionValue = argAnnotation.value("description"); - if (descriptionValue != null) { - description = descriptionValue.asString(); - } - AnnotationValue requiredValue = argAnnotation.value("required"); - if (requiredValue != null) { - required = requiredValue.asBoolean(); + if (argAnnotationName != null) { + AnnotationInstance argAnnotation = pi.declaredAnnotation(argAnnotationName); + if (argAnnotation != null) { + AnnotationValue nameValue = argAnnotation.value("name"); + if (nameValue != null) { + name = nameValue.asString(); + } + AnnotationValue descriptionValue = argAnnotation.value("description"); + if (descriptionValue != null) { + description = descriptionValue.asString(); + } + AnnotationValue requiredValue = argAnnotation.value("required"); + if (requiredValue != null) { + required = requiredValue.asBoolean(); + } } } - ResultHandle type = Types.getTypeHandle(method, pi.type()); + ResultHandle type = Types.getTypeHandle(metaMethod, pi.type()); ResultHandle provider; if (pi.type().name().equals(DotNames.MCP_CONNECTION)) { - provider = method.load(FeatureArgument.Provider.MCP_CONNECTION); + provider = metaMethod.load(FeatureArgument.Provider.MCP_CONNECTION); } else if (pi.type().name().equals(DotNames.REQUEST_ID)) { - provider = method.load(FeatureArgument.Provider.REQUEST_ID); + provider = metaMethod.load(FeatureArgument.Provider.REQUEST_ID); } else { - provider = method.load(FeatureArgument.Provider.PARAMS); + provider = metaMethod.load(FeatureArgument.Provider.PARAMS); } - ResultHandle arg = method.newInstance( + ResultHandle arg = metaMethod.newInstance( MethodDescriptor.ofConstructor(FeatureArgument.class, String.class, String.class, boolean.class, Type.class, FeatureArgument.Provider.class), - method.load(name), method.load(description), method.load(required), type, + metaMethod.load(name), metaMethod.load(description), metaMethod.load(required), type, provider); - Gizmo.listOperations(method).on(args).add(arg); + Gizmo.listOperations(metaMethod).on(args).add(arg); } - ResultHandle info = method.newInstance( - MethodDescriptor.ofConstructor(FeatureMethodInfo.class, String.class, String.class, List.class), - method.load(featureMethod.getName()), method.load(featureMethod.getDescription()), args); - ResultHandle invoker = method + ResultHandle info = metaMethod.newInstance( + MethodDescriptor.ofConstructor(FeatureMethodInfo.class, String.class, String.class, String.class, String.class, + List.class), + metaMethod.load(featureMethod.getName()), metaMethod.load(featureMethod.getDescription()), + featureMethod.getUri() == null ? metaMethod.loadNull() : metaMethod.load(featureMethod.getUri()), + featureMethod.getMimeType() == null ? metaMethod.loadNull() : metaMethod.load(featureMethod.getMimeType()), + args); + ResultHandle invoker = metaMethod .newInstance(MethodDescriptor.ofConstructor(featureMethod.getInvoker().getClassName())); - ResultHandle executionModel = method.load(executionModel(featureMethod.getMethod(), transformedAnnotations)); - ResultHandle resultMapper = getMapper(method, featureMethod.getMethod().returnType(), featureMethod.getFeature()); - ResultHandle metadata = method.newInstance( - MethodDescriptor.ofConstructor(FeatureMetadata.class, FeatureMethodInfo.class, Invoker.class, + ResultHandle executionModel = metaMethod.load(executionModel(featureMethod.getMethod(), transformedAnnotations)); + ResultHandle resultMapper = getMapper(metaMethod, featureMethod.getMethod().returnType(), featureMethod.getFeature()); + ResultHandle metadata = metaMethod.newInstance( + MethodDescriptor.ofConstructor(FeatureMetadata.class, Feature.class, FeatureMethodInfo.class, Invoker.class, ExecutionModel.class, Function.class), - info, invoker, executionModel, resultMapper); - Gizmo.listOperations(method).on(retList).add(metadata); + metaMethod.load(featureMethod.getFeature()), info, invoker, executionModel, resultMapper); + metaMethod.returnValue(metadata); + + Gizmo.listOperations(method).on(retList) + .add(method.invokeVirtualMethod(metaMethod.getMethodDescriptor(), method.getThis())); } private ResultHandle getMapper(BytecodeCreator bytecode, org.jboss.jandex.Type returnType, - FeatureMethodBuildItem.Feature feature) { + Feature feature) { // At this point the method return type is already validated return switch (feature) { case PROMPT -> promptMapper(bytecode, returnType); case TOOL -> toolMapper(bytecode, returnType); + case RESOURCE -> resourceMapper(bytecode, returnType); default -> throw new IllegalArgumentException("Unsupported feature: " + feature); }; } @@ -388,11 +482,36 @@ private ResultHandle toolMapper(BytecodeCreator bytecode, org.jboss.jandex.Type throw new IllegalArgumentException("Unsupported return type"); } + 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"); + } + private boolean isContent(DotName typeName) { return DotNames.CONTENT.equals(typeName) || DotNames.TEXT_CONTENT.equals(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) { return bytecode.readStaticField(FieldDescriptor.of(ResultMappers.class, contantName, Function.class)); } diff --git a/deployment/src/test/java/io/quarkiverse/mcp/server/test/McpServerTest.java b/deployment/src/test/java/io/quarkiverse/mcp/server/test/McpServerTest.java index 7ba9f86..8464cd0 100644 --- a/deployment/src/test/java/io/quarkiverse/mcp/server/test/McpServerTest.java +++ b/deployment/src/test/java/io/quarkiverse/mcp/server/test/McpServerTest.java @@ -16,6 +16,7 @@ import org.jboss.resteasy.reactive.client.SseEvent; import io.quarkus.rest.client.reactive.QuarkusRestClientBuilder; +import io.quarkus.test.QuarkusUnitTest; import io.quarkus.test.common.http.TestHTTPResource; import io.restassured.http.ContentType; import io.vertx.core.json.JsonObject; @@ -27,6 +28,15 @@ public abstract class McpServerTest { @TestHTTPResource URI testUri; + public static QuarkusUnitTest defaultConfig() { + QuarkusUnitTest config = new QuarkusUnitTest(); + if (System.getProperty("logTraffic") != null) { + config.overrideConfigKey("quarkus.mcp.server.traffic-logging.enabled", "true"); + config.overrideConfigKey("quarkus.log.category.\"io.quarkus.mcp.server.traffic\".level", "DEBUG"); + } + return config; + } + protected List> sseMessages; AtomicInteger idGenerator = new AtomicInteger(); diff --git a/deployment/src/test/java/io/quarkiverse/mcp/server/test/close/CloseTest.java b/deployment/src/test/java/io/quarkiverse/mcp/server/test/close/CloseTest.java index 30b2ba3..2e9db5b 100644 --- a/deployment/src/test/java/io/quarkiverse/mcp/server/test/close/CloseTest.java +++ b/deployment/src/test/java/io/quarkiverse/mcp/server/test/close/CloseTest.java @@ -17,7 +17,7 @@ public class CloseTest extends McpServerTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusUnitTest config = defaultConfig() .withApplicationRoot(root -> root.addClasses(McpClient.class)); @Test diff --git a/deployment/src/test/java/io/quarkiverse/mcp/server/test/ping/PingTest.java b/deployment/src/test/java/io/quarkiverse/mcp/server/test/ping/PingTest.java index df7df25..93dfaca 100644 --- a/deployment/src/test/java/io/quarkiverse/mcp/server/test/ping/PingTest.java +++ b/deployment/src/test/java/io/quarkiverse/mcp/server/test/ping/PingTest.java @@ -19,7 +19,7 @@ public class PingTest extends McpServerTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusUnitTest config = defaultConfig() .withApplicationRoot(root -> root.addClasses(McpClient.class)); @Test diff --git a/deployment/src/test/java/io/quarkiverse/mcp/server/test/prompts/PromptsTest.java b/deployment/src/test/java/io/quarkiverse/mcp/server/test/prompts/PromptsTest.java index ce551a1..693a203 100644 --- a/deployment/src/test/java/io/quarkiverse/mcp/server/test/prompts/PromptsTest.java +++ b/deployment/src/test/java/io/quarkiverse/mcp/server/test/prompts/PromptsTest.java @@ -24,7 +24,7 @@ public class PromptsTest extends McpServerTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusUnitTest config = defaultConfig() .withApplicationRoot( root -> root.addClasses(McpClient.class, FooService.class, Options.class, Checks.class, MyPrompts.class)); diff --git a/deployment/src/test/java/io/quarkiverse/mcp/server/test/resources/MyResources.java b/deployment/src/test/java/io/quarkiverse/mcp/server/test/resources/MyResources.java new file mode 100644 index 0000000..ed6365f --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/mcp/server/test/resources/MyResources.java @@ -0,0 +1,47 @@ +package io.quarkiverse.mcp.server.test.resources; + +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.Resource; +import io.quarkiverse.mcp.server.ResourceResponse; +import io.quarkiverse.mcp.server.TextResourceContents; +import io.smallrye.mutiny.Uni; + +public class MyResources { + + @Resource(uri = "file:///project/alpha") + ResourceResponse alpha(String uri) { + checkExecutionModel(true); + checkDuplicatedContext(); + checkRequestContext(); + return new ResourceResponse(List.of(new TextResourceContents(uri, "1", null))); + } + + @Resource(uri = "file:///project/uni_alpha") + Uni uni_alpha(String uri) { + checkExecutionModel(false); + checkDuplicatedContext(); + checkRequestContext(); + return Uni.createFrom().item(new ResourceResponse(List.of(new TextResourceContents(uri, "2", null)))); + } + + @Resource(uri = "file:///project/bravo") + TextResourceContents bravo(String uri) { + checkExecutionModel(true); + checkDuplicatedContext(); + checkRequestContext(); + return new TextResourceContents(uri, "3", null); + } + + @Resource(uri = "file:///project/uni_bravo") + Uni uni_bravo() { + checkExecutionModel(false); + checkDuplicatedContext(); + checkRequestContext(); + return Uni.createFrom().item(new TextResourceContents("file:///foo", "4", null)); + } +} diff --git a/deployment/src/test/java/io/quarkiverse/mcp/server/test/resources/ResourcesTest.java b/deployment/src/test/java/io/quarkiverse/mcp/server/test/resources/ResourcesTest.java new file mode 100644 index 0000000..16c6edd --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/mcp/server/test/resources/ResourcesTest.java @@ -0,0 +1,94 @@ +package io.quarkiverse.mcp.server.test.resources; + +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.McpClient; +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 ResourcesTest extends McpServerTest { + + @RegisterExtension + static final QuarkusUnitTest config = defaultConfig() + .withApplicationRoot( + root -> root.addClasses(McpClient.class, MyResources.class, Checks.class)); + + @Test + public void testResources() throws URISyntaxException { + URI endpoint = initClient(); + + JsonObject resourcesListMessage = newMessage("resources/list"); + + JsonObject resourcesListResponse = new JsonObject(given() + .contentType(ContentType.JSON) + .when() + .body(resourcesListMessage.encode()) + .post(endpoint) + .then() + .statusCode(200) + .extract().body().asString()); + + JsonObject resourcesListResult = assertResponseMessage(resourcesListMessage, resourcesListResponse); + assertNotNull(resourcesListResult); + JsonArray resources = resourcesListResult.getJsonArray("resources"); + assertEquals(4, resources.size()); + + // alpha, bravo, uni_alpha, uni_bravo + assertResource(resources.getJsonObject(0), "alpha", null, "file:///project/alpha", null); + assertResource(resources.getJsonObject(1), "bravo", null, "file:///project/bravo", null); + assertResource(resources.getJsonObject(2), "uni_alpha", null, "file:///project/uni_alpha", null); + assertResource(resources.getJsonObject(3), "uni_bravo", null, "file:///project/uni_bravo", null); + + assertResourceRead("1", "file:///project/alpha", endpoint, "file:///project/alpha"); + assertResourceRead("2", "file:///project/uni_alpha", endpoint, "file:///project/uni_alpha"); + assertResourceRead("3", "file:///project/bravo", endpoint, "file:///project/bravo"); + assertResourceRead("4", "file:///foo", endpoint, "file:///project/uni_bravo"); + } + + private void assertResource(JsonObject resource, String name, String description, String uri, String mimeType) { + assertEquals(name, resource.getString("name")); + if (description != null) { + assertEquals(description, resource.getString("description")); + } + assertEquals(uri, resource.getString("uri")); + if (mimeType != null) { + assertEquals(description, resource.getString("mimeType")); + } + } + + private void assertResourceRead(String expectedText, String expectedUri, URI endpoint, String uri) { + JsonObject resourceReadMessage = newMessage("resources/read") + .put("params", new JsonObject() + .put("uri", uri)); + + JsonObject resourceReadResponse = new JsonObject(given() + .contentType(ContentType.JSON) + .when() + .body(resourceReadMessage.encode()) + .post(endpoint) + .then() + .statusCode(200) + .extract().body().asString()); + + 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(expectedUri, textContent.getString("uri")); + } + +} diff --git a/deployment/src/test/java/io/quarkiverse/mcp/server/test/serverinfo/CustomServerInfoTest.java b/deployment/src/test/java/io/quarkiverse/mcp/server/test/serverinfo/CustomServerInfoTest.java index 683cbfe..0d3fb69 100644 --- a/deployment/src/test/java/io/quarkiverse/mcp/server/test/serverinfo/CustomServerInfoTest.java +++ b/deployment/src/test/java/io/quarkiverse/mcp/server/test/serverinfo/CustomServerInfoTest.java @@ -19,10 +19,10 @@ public class CustomServerInfoTest extends McpServerTest { private static final String VERSION = "1.0"; @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusUnitTest config = defaultConfig() .withApplicationRoot(root -> root.addClasses(McpClient.class)) - .overrideConfigKey("quarkus.mcp-server.server-info.name", NAME) - .overrideConfigKey("quarkus.mcp-server.server-info.version", VERSION); + .overrideConfigKey("quarkus.mcp.server.server-info.name", NAME) + .overrideConfigKey("quarkus.mcp.server.server-info.version", VERSION); @Test public void testServerInfo() throws URISyntaxException { diff --git a/deployment/src/test/java/io/quarkiverse/mcp/server/test/serverinfo/DefaultServerInfoTest.java b/deployment/src/test/java/io/quarkiverse/mcp/server/test/serverinfo/DefaultServerInfoTest.java index 2b041b4..868560f 100644 --- a/deployment/src/test/java/io/quarkiverse/mcp/server/test/serverinfo/DefaultServerInfoTest.java +++ b/deployment/src/test/java/io/quarkiverse/mcp/server/test/serverinfo/DefaultServerInfoTest.java @@ -16,7 +16,7 @@ public class DefaultServerInfoTest extends McpServerTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusUnitTest config = defaultConfig() .withApplicationRoot(root -> root.addClasses(McpClient.class)); @Test diff --git a/deployment/src/test/java/io/quarkiverse/mcp/server/test/tools/ToolsTest.java b/deployment/src/test/java/io/quarkiverse/mcp/server/test/tools/ToolsTest.java index 37a37ba..f599083 100644 --- a/deployment/src/test/java/io/quarkiverse/mcp/server/test/tools/ToolsTest.java +++ b/deployment/src/test/java/io/quarkiverse/mcp/server/test/tools/ToolsTest.java @@ -24,7 +24,7 @@ public class ToolsTest extends McpServerTest { @RegisterExtension - static final QuarkusUnitTest config = new QuarkusUnitTest() + static final QuarkusUnitTest config = defaultConfig() .withApplicationRoot( root -> root.addClasses(McpClient.class, FooService.class, Options.class, Checks.class, MyTools.class)); diff --git a/deployment/src/test/java/io/quarkiverse/mcp/server/test/validation/DuplicateResourceUriTest.java b/deployment/src/test/java/io/quarkiverse/mcp/server/test/validation/DuplicateResourceUriTest.java new file mode 100644 index 0000000..26a514a --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/mcp/server/test/validation/DuplicateResourceUriTest.java @@ -0,0 +1,40 @@ +package io.quarkiverse.mcp.server.test.validation; + +import static org.junit.jupiter.api.Assertions.fail; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkiverse.mcp.server.Resource; +import io.quarkiverse.mcp.server.ResourceResponse; +import io.quarkus.test.QuarkusUnitTest; + +public class DuplicateResourceUriTest { + + @RegisterExtension + public static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(root -> { + root.addClasses(InvalidResources.class); + }) + .setExpectedException(IllegalStateException.class, true); + + @Test + public void test() { + fail(); + } + + public static class InvalidResources { + + @Resource(uri = "baz") + ResourceResponse foo() { + return null; + } + + @Resource(uri = "baz") + ResourceResponse foos() { + return null; + } + + } + +} diff --git a/deployment/src/test/java/io/quarkiverse/mcp/server/test/validation/ResourceInvalidParamTest.java b/deployment/src/test/java/io/quarkiverse/mcp/server/test/validation/ResourceInvalidParamTest.java new file mode 100644 index 0000000..a39310a --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/mcp/server/test/validation/ResourceInvalidParamTest.java @@ -0,0 +1,35 @@ +package io.quarkiverse.mcp.server.test.validation; + +import static org.junit.jupiter.api.Assertions.fail; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkiverse.mcp.server.Resource; +import io.quarkiverse.mcp.server.ResourceResponse; +import io.quarkus.test.QuarkusUnitTest; + +public class ResourceInvalidParamTest { + + @RegisterExtension + public static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(root -> { + root.addClasses(InvalidParam.class); + }) + .setExpectedException(IllegalStateException.class, true); + + @Test + public void test() { + fail(); + } + + public static class InvalidParam { + + @Resource(uri = "fooo") + ResourceResponse foo(int jo, boolean ne) { + return null; + } + + } + +} diff --git a/docs/modules/ROOT/pages/includes/quarkus-mcp-server.adoc b/docs/modules/ROOT/pages/includes/quarkus-mcp-server.adoc index 5daf3cd..cf4a904 100644 --- a/docs/modules/ROOT/pages/includes/quarkus-mcp-server.adoc +++ b/docs/modules/ROOT/pages/includes/quarkus-mcp-server.adoc @@ -7,7 +7,7 @@ h|[.header-title]##Configuration property## h|Type h|Default -a|icon:lock[title=Fixed at build time] [[quarkus-mcp-server_quarkus-mcp-server-root-path]] [.property-path]##link:#quarkus-mcp-server_quarkus-mcp-server-root-path[`quarkus.mcp-server.root-path`]## +a|icon:lock[title=Fixed at build time] [[quarkus-mcp-server_quarkus-mcp-server-root-path]] [.property-path]##link:#quarkus-mcp-server_quarkus-mcp-server-root-path[`quarkus.mcp.server.root-path`]## [.description] -- @@ -24,7 +24,7 @@ endif::add-copy-button-to-env-var[] |string |`/mcp` -a|icon:lock[title=Fixed at build time] [[quarkus-mcp-server_quarkus-mcp-server-server-info-name]] [.property-path]##link:#quarkus-mcp-server_quarkus-mcp-server-server-info-name[`quarkus.mcp-server.server-info.name`]## +a| [[quarkus-mcp-server_quarkus-mcp-server-server-info-name]] [.property-path]##link:#quarkus-mcp-server_quarkus-mcp-server-server-info-name[`quarkus.mcp.server.server-info.name`]## [.description] -- @@ -43,7 +43,7 @@ endif::add-copy-button-to-env-var[] |string | -a|icon:lock[title=Fixed at build time] [[quarkus-mcp-server_quarkus-mcp-server-server-info-version]] [.property-path]##link:#quarkus-mcp-server_quarkus-mcp-server-server-info-version[`quarkus.mcp-server.server-info.version`]## +a| [[quarkus-mcp-server_quarkus-mcp-server-server-info-version]] [.property-path]##link:#quarkus-mcp-server_quarkus-mcp-server-server-info-version[`quarkus.mcp.server.server-info.version`]## [.description] -- @@ -62,5 +62,39 @@ endif::add-copy-button-to-env-var[] |string | +a| [[quarkus-mcp-server_quarkus-mcp-server-traffic-logging-enabled]] [.property-path]##link:#quarkus-mcp-server_quarkus-mcp-server-traffic-logging-enabled[`quarkus.mcp.server.traffic-logging.enabled`]## + +[.description] +-- +If set to true then JSON messages received/sent are logged if the `DEBUG` level is enabled for the logger `io.quarkus.mcp.server.traffic`. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_MCP_SERVER_TRAFFIC_LOGGING_ENABLED+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_MCP_SERVER_TRAFFIC_LOGGING_ENABLED+++` +endif::add-copy-button-to-env-var[] +-- +|boolean +|`false` + +a| [[quarkus-mcp-server_quarkus-mcp-server-traffic-logging-text-limit]] [.property-path]##link:#quarkus-mcp-server_quarkus-mcp-server-traffic-logging-text-limit[`quarkus.mcp.server.traffic-logging.text-limit`]## + +[.description] +-- +The number of characters of a text message which will be logged if traffic logging is enabled. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_MCP_SERVER_TRAFFIC_LOGGING_TEXT_LIMIT+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_MCP_SERVER_TRAFFIC_LOGGING_TEXT_LIMIT+++` +endif::add-copy-button-to-env-var[] +-- +|int +|`100` + |=== diff --git a/docs/modules/ROOT/pages/includes/quarkus-mcp-server_quarkus.adoc b/docs/modules/ROOT/pages/includes/quarkus-mcp-server_quarkus.adoc index 8f5bd1d..00040d7 100644 --- a/docs/modules/ROOT/pages/includes/quarkus-mcp-server_quarkus.adoc +++ b/docs/modules/ROOT/pages/includes/quarkus-mcp-server_quarkus.adoc @@ -7,12 +7,70 @@ h|[.header-title]##Configuration property## h|Type h|Default -a|icon:lock[title=Fixed at build time] [[quarkus-mcp-server_quarkus-root-path]] [.property-path]##link:#quarkus-mcp-server_quarkus-root-path[`quarkus.root-path`]## +a|icon:lock[title=Fixed at build time] [[quarkus-mcp-server_quarkus-server-info-name]] [.property-path]##link:#quarkus-mcp-server_quarkus-server-info-name[`quarkus.server-info.name`]## + +[.description] +-- + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_SERVER_INFO_NAME+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_SERVER_INFO_NAME+++` +endif::add-copy-button-to-env-var[] +-- +|string +| + +a|icon:lock[title=Fixed at build time] [[quarkus-mcp-server_quarkus-server-info-version]] [.property-path]##link:#quarkus-mcp-server_quarkus-server-info-version[`quarkus.server-info.version`]## [.description] -- -The root path. +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_SERVER_INFO_VERSION+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_SERVER_INFO_VERSION+++` +endif::add-copy-button-to-env-var[] +-- +|string +| + +a|icon:lock[title=Fixed at build time] [[quarkus-mcp-server_quarkus-traffic-logging-enabled]] [.property-path]##link:#quarkus-mcp-server_quarkus-traffic-logging-enabled[`quarkus.traffic-logging.enabled`]## + +[.description] +-- + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_TRAFFIC_LOGGING_ENABLED+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_TRAFFIC_LOGGING_ENABLED+++` +endif::add-copy-button-to-env-var[] +-- +|boolean +|`false` + +a|icon:lock[title=Fixed at build time] [[quarkus-mcp-server_quarkus-traffic-logging-text-limit]] [.property-path]##link:#quarkus-mcp-server_quarkus-traffic-logging-text-limit[`quarkus.traffic-logging.text-limit`]## + +[.description] +-- + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_TRAFFIC_LOGGING_TEXT_LIMIT+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_TRAFFIC_LOGGING_TEXT_LIMIT+++` +endif::add-copy-button-to-env-var[] +-- +|int +|`100` + +a|icon:lock[title=Fixed at build time] [[quarkus-mcp-server_quarkus-root-path]] [.property-path]##link:#quarkus-mcp-server_quarkus-root-path[`quarkus.root-path`]## + +[.description] +-- ifdef::add-copy-button-to-env-var[] Environment variable: env_var_with_copy_button:+++QUARKUS_ROOT_PATH+++[] diff --git a/docs/modules/ROOT/pages/includes/quarkus-mcp-server_quarkus.mcp.adoc b/docs/modules/ROOT/pages/includes/quarkus-mcp-server_quarkus.mcp.adoc new file mode 100644 index 0000000..cf4a904 --- /dev/null +++ b/docs/modules/ROOT/pages/includes/quarkus-mcp-server_quarkus.mcp.adoc @@ -0,0 +1,100 @@ +[.configuration-legend] +icon:lock[title=Fixed at build time] Configuration property fixed at build time - All other configuration properties are overridable at runtime +[.configuration-reference.searchable, cols="80,.^10,.^10"] +|=== + +h|[.header-title]##Configuration property## +h|Type +h|Default + +a|icon:lock[title=Fixed at build time] [[quarkus-mcp-server_quarkus-mcp-server-root-path]] [.property-path]##link:#quarkus-mcp-server_quarkus-mcp-server-root-path[`quarkus.mcp.server.root-path`]## + +[.description] +-- +The root path. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_MCP_SERVER_ROOT_PATH+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_MCP_SERVER_ROOT_PATH+++` +endif::add-copy-button-to-env-var[] +-- +|string +|`/mcp` + +a| [[quarkus-mcp-server_quarkus-mcp-server-server-info-name]] [.property-path]##link:#quarkus-mcp-server_quarkus-mcp-server-server-info-name[`quarkus.mcp.server.server-info.name`]## + +[.description] +-- +The name of the server is included in the response to an `initialize` request as defined by the +https://spec.modelcontextprotocol.io/specification/basic/lifecycle/#initialization[spec]. +By default, the value of the `quarkus.application.name` config property is used. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_MCP_SERVER_SERVER_INFO_NAME+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_MCP_SERVER_SERVER_INFO_NAME+++` +endif::add-copy-button-to-env-var[] +-- +|string +| + +a| [[quarkus-mcp-server_quarkus-mcp-server-server-info-version]] [.property-path]##link:#quarkus-mcp-server_quarkus-mcp-server-server-info-version[`quarkus.mcp.server.server-info.version`]## + +[.description] +-- +The version of the server is included in the response to an `initialize` request as defined by the +https://spec.modelcontextprotocol.io/specification/basic/lifecycle/#initialization[spec]. +By default, the value of the `quarkus.application.version` config property is used. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_MCP_SERVER_SERVER_INFO_VERSION+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_MCP_SERVER_SERVER_INFO_VERSION+++` +endif::add-copy-button-to-env-var[] +-- +|string +| + +a| [[quarkus-mcp-server_quarkus-mcp-server-traffic-logging-enabled]] [.property-path]##link:#quarkus-mcp-server_quarkus-mcp-server-traffic-logging-enabled[`quarkus.mcp.server.traffic-logging.enabled`]## + +[.description] +-- +If set to true then JSON messages received/sent are logged if the `DEBUG` level is enabled for the logger `io.quarkus.mcp.server.traffic`. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_MCP_SERVER_TRAFFIC_LOGGING_ENABLED+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_MCP_SERVER_TRAFFIC_LOGGING_ENABLED+++` +endif::add-copy-button-to-env-var[] +-- +|boolean +|`false` + +a| [[quarkus-mcp-server_quarkus-mcp-server-traffic-logging-text-limit]] [.property-path]##link:#quarkus-mcp-server_quarkus-mcp-server-traffic-logging-text-limit[`quarkus.mcp.server.traffic-logging.text-limit`]## + +[.description] +-- +The number of characters of a text message which will be logged if traffic logging is enabled. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_MCP_SERVER_TRAFFIC_LOGGING_TEXT_LIMIT+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_MCP_SERVER_TRAFFIC_LOGGING_TEXT_LIMIT+++` +endif::add-copy-button-to-env-var[] +-- +|int +|`100` + +|=== + diff --git a/runtime/src/main/java/io/quarkiverse/mcp/server/BlobResourceContents.java b/runtime/src/main/java/io/quarkiverse/mcp/server/BlobResourceContents.java new file mode 100644 index 0000000..6b10c05 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/mcp/server/BlobResourceContents.java @@ -0,0 +1,36 @@ +package io.quarkiverse.mcp.server; + +import java.util.Base64; + +/** + * + * @param uri + * @param blob A base64-encoded string representing the binary data of the item + * @param mimeType + */ +public record BlobResourceContents(String uri, String blob, String mimeType) implements ResourceContents { + + public static BlobResourceContents create(String uri, String blob) { + return new BlobResourceContents(uri, blob, null); + } + + public static BlobResourceContents create(String uri, byte[] blob) { + return new BlobResourceContents(uri, Base64.getMimeEncoder().encodeToString(blob), null); + } + + @Override + public Type type() { + return Type.BLOB; + } + + @Override + public TextResourceContents asText() { + throw new IllegalArgumentException("Not a text"); + } + + @Override + public BlobResourceContents asBlob() { + return this; + } + +} diff --git a/runtime/src/main/java/io/quarkiverse/mcp/server/Resource.java b/runtime/src/main/java/io/quarkiverse/mcp/server/Resource.java new file mode 100644 index 0000000..5adff52 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/mcp/server/Resource.java @@ -0,0 +1,56 @@ +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. + *

+ * 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 Resource { + + /** + * Constant value for {@link #name()} indicating that the annotated element's name should be used as-is. + */ + String ELEMENT_NAME = "<>"; + + /** + * "A human-readable name for this resource." + */ + String name() default ELEMENT_NAME; + + /** + * "A description of what this resource represents." + */ + String description() default ""; + + /** + * "The URI of this resource." + */ + String uri(); + + /** + * "The MIME type of this resource, if known." + */ + String mimeType() default ""; + +} diff --git a/runtime/src/main/java/io/quarkiverse/mcp/server/ResourceContent.java b/runtime/src/main/java/io/quarkiverse/mcp/server/ResourceContent.java index 84b8d53..0d427ec 100644 --- a/runtime/src/main/java/io/quarkiverse/mcp/server/ResourceContent.java +++ b/runtime/src/main/java/io/quarkiverse/mcp/server/ResourceContent.java @@ -1,5 +1,13 @@ package io.quarkiverse.mcp.server; +/** + * + * @param uri + * @param mimeType + * @param text + * @see Prompt + * @see Tool + */ public record ResourceContent(String uri, String mimeType, String text) implements Content { @Override diff --git a/runtime/src/main/java/io/quarkiverse/mcp/server/ResourceContents.java b/runtime/src/main/java/io/quarkiverse/mcp/server/ResourceContents.java new file mode 100644 index 0000000..5f52ab0 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/mcp/server/ResourceContents.java @@ -0,0 +1,16 @@ +package io.quarkiverse.mcp.server; + +public sealed interface ResourceContents permits TextResourceContents, BlobResourceContents { + + Type type(); + + TextResourceContents asText(); + + BlobResourceContents asBlob(); + + enum Type { + TEXT, + BLOB + } + +} diff --git a/runtime/src/main/java/io/quarkiverse/mcp/server/ResourceResponse.java b/runtime/src/main/java/io/quarkiverse/mcp/server/ResourceResponse.java new file mode 100644 index 0000000..c664897 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/mcp/server/ResourceResponse.java @@ -0,0 +1,11 @@ +package io.quarkiverse.mcp.server; + +import java.util.List; + +/** + * + * @param contents + */ +public record ResourceResponse(List contents) { + +} diff --git a/runtime/src/main/java/io/quarkiverse/mcp/server/TextResourceContents.java b/runtime/src/main/java/io/quarkiverse/mcp/server/TextResourceContents.java new file mode 100644 index 0000000..a4e155f --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/mcp/server/TextResourceContents.java @@ -0,0 +1,30 @@ +package io.quarkiverse.mcp.server; + +/** + * + * @param uri + * @param text + * @param mimeType + */ +public record TextResourceContents(String uri, String text, String mimeType) implements ResourceContents { + + public static TextResourceContents create(String uri, String text) { + return new TextResourceContents(uri, text, null); + } + + @Override + public Type type() { + return Type.TEXT; + } + + @Override + public TextResourceContents asText() { + return this; + } + + @Override + public BlobResourceContents asBlob() { + throw new IllegalArgumentException("Not a blob"); + } + +} diff --git a/runtime/src/main/java/io/quarkiverse/mcp/server/Tool.java b/runtime/src/main/java/io/quarkiverse/mcp/server/Tool.java index 59c0a4c..d225d62 100644 --- a/runtime/src/main/java/io/quarkiverse/mcp/server/Tool.java +++ b/runtime/src/main/java/io/quarkiverse/mcp/server/Tool.java @@ -13,8 +13,8 @@ * Annotates a business method of a CDI bean as an exposed tool. *

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

    *
  • If the method returns an implementation of {@link Content} then the reponse is "success" and contains the single * content object.
  • diff --git a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/FeatureArgument.java b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/FeatureArgument.java index 39b46aa..754b88c 100644 --- a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/FeatureArgument.java +++ b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/FeatureArgument.java @@ -1,11 +1,17 @@ package io.quarkiverse.mcp.server.runtime; -import com.fasterxml.jackson.annotation.JsonIgnore; +import io.vertx.core.json.JsonObject; -public record FeatureArgument(String name, String description, boolean required, @JsonIgnore java.lang.reflect.Type type, - @JsonIgnore Provider provider) { +public record FeatureArgument(String name, String description, boolean required, java.lang.reflect.Type type, + Provider provider) { + + public JsonObject asJson() { + return new JsonObject() + .put("name", name) + .put("description", description) + .put("required", required); + } - @JsonIgnore public boolean isParam() { return provider == Provider.PARAMS; } diff --git a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/FeatureManager.java b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/FeatureManager.java index 37a7250..8ef2b06 100644 --- a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/FeatureManager.java +++ b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/FeatureManager.java @@ -53,6 +53,12 @@ public Uni call() throws Exception { } + public abstract List> list(); + + public boolean isEmpty() { + return list().isEmpty(); + } + @SuppressWarnings("unchecked") private Object[] prepareArguments(FeatureMethodInfo info, ArgumentProviders argProviders) { Object[] ret = new Object[info.arguments().size()]; @@ -92,7 +98,7 @@ private Object[] prepareArguments(FeatureMethodInfo info, ArgumentProviders argP return ret; } - protected abstract FeatureMetadata getMetadata(String name); + protected abstract FeatureMetadata getMetadata(String identifier); protected Future execute(ExecutionModel executionModel, Callable> action) { Promise ret = Promise.promise(); diff --git a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/FeatureMetadata.java b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/FeatureMetadata.java index 16e3236..ea1369e 100644 --- a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/FeatureMetadata.java +++ b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/FeatureMetadata.java @@ -5,12 +5,42 @@ import jakarta.enterprise.invoke.Invoker; import io.smallrye.mutiny.Uni; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; /** * * @param The response message */ -public record FeatureMetadata(FeatureMethodInfo info, Invoker invoker, ExecutionModel executionModel, - Function> resultMapper) { +public record FeatureMetadata(Feature feature, FeatureMethodInfo info, Invoker invoker, + ExecutionModel executionModel, + Function> resultMapper) implements Comparable> { + + @Override + public int compareTo(FeatureMetadata o) { + return info.name().compareTo(o.info.name()); + } + + public JsonObject asJson() { + JsonObject json = new JsonObject().put("name", info.name()) + .put("description", info.description()); + if (feature == Feature.PROMPT) { + JsonArray arguments = new JsonArray(); + for (FeatureArgument arg : info.serializedArguments()) { + arguments.add(arg.asJson()); + } + json.put("arguments", arguments); + } else if (feature == Feature.RESOURCE) { + json.put("uri", info.uri()) + .put("mimeType", info.mimeType()); + } + return json; + } + + public enum Feature { + PROMPT, + TOOL, + RESOURCE + } } diff --git a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/FeatureMethodInfo.java b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/FeatureMethodInfo.java index 25f3a18..346cafc 100644 --- a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/FeatureMethodInfo.java +++ b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/FeatureMethodInfo.java @@ -2,12 +2,8 @@ import java.util.List; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonProperty; +public record FeatureMethodInfo(String name, String description, String uri, String mimeType, List arguments) { -public record FeatureMethodInfo(String name, String description, @JsonIgnore List arguments) { - - @JsonProperty("arguments") public List serializedArguments() { if (arguments == null) { return List.of(); diff --git a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/McpMessagesHandler.java b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/McpMessagesHandler.java index 16192c2..dc18e69 100644 --- a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/McpMessagesHandler.java +++ b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/McpMessagesHandler.java @@ -11,6 +11,7 @@ import io.quarkiverse.mcp.server.ClientCapability; import io.quarkiverse.mcp.server.Implementation; import io.quarkiverse.mcp.server.InitializeRequest; +import io.quarkiverse.mcp.server.runtime.config.McpRuntimeConfig; import io.vertx.core.Handler; import io.vertx.core.http.HttpHeaders; import io.vertx.core.http.HttpMethod; @@ -28,91 +29,96 @@ class McpMessagesHandler implements Handler { private final PromptMessageHandler promptHandler; - private final McpBuildTimeConfig config; + private final ResourceMessageHandler resourceHandler; - McpMessagesHandler(ConnectionManager connectionManager, McpBuildTimeConfig config) { + private final McpRuntimeConfig config; + + private final Map serverInfo; + + private final TrafficLogger trafficLogger; + + McpMessagesHandler(McpRuntimeConfig config, ConnectionManager connectionManager, PromptManager promptManager, + ToolManager toolManager, ResourceManager resourceManager) { this.connectionManager = connectionManager; - this.toolHandler = new ToolMessageHandler(); - this.promptHandler = new PromptMessageHandler(); + this.toolHandler = new ToolMessageHandler(toolManager); + this.promptHandler = new PromptMessageHandler(promptManager); + this.resourceHandler = new ResourceMessageHandler(resourceManager); this.config = config; + this.serverInfo = serverInfo(promptManager, toolManager, resourceManager); + this.trafficLogger = config.trafficLogging().enabled() ? new TrafficLogger(config.trafficLogging().textLimit()) : null; } @Override public void handle(RoutingContext ctx) { + Responder responder = new Responder(trafficLogger, ctx); HttpServerRequest request = ctx.request(); String id = ctx.pathParam("id"); if (id == null) { - LOG.error("Connection id is missing"); - ctx.fail(400); + responder.badRequest("Connection id is missing"); return; } if (request.method() != HttpMethod.POST) { - LOG.errorf("Invalid HTTP method %s [connectionId: %s]", ctx.request().method(), id); ctx.response().putHeader(HttpHeaders.ALLOW, "POST"); - ctx.fail(405); + responder.failure(405, id, "Invalid HTTP method %s [connectionId: %s]", ctx.request().method(), id); return; } McpConnectionImpl connection = connectionManager.get(id); if (connection == null) { - LOG.errorf("Connection %s not found", id); - ctx.fail(400); + responder.badRequest("Connection %s not found", id); return; } JsonObject message = ctx.body().asJsonObject(); + if (trafficLogger != null) { + trafficLogger.messageReceived(message); + } String jsonrpc = message.getString("jsonrpc"); if (!"2.0".equals(jsonrpc)) { - LOG.errorf("Invalid jsonrpc version %s [connectionId: %s]", message, id); - ctx.fail(400); + responder.badRequest("Invalid jsonrpc version %s [connectionId: %s]", message, id); return; } switch (connection.status()) { - case NEW -> initializeNew(message, ctx, connection); - case INITIALIZING -> initializing(message, ctx, connection); - case IN_OPERATION -> operation(message, ctx, connection); + case NEW -> initializeNew(message, responder, connection); + case INITIALIZING -> initializing(message, responder, connection); + case IN_OPERATION -> operation(message, responder, connection); case SHUTDOWN -> ctx.fail(400); } } - private void initializeNew(JsonObject message, RoutingContext ctx, McpConnectionImpl connection) { + private void initializeNew(JsonObject message, Responder responder, McpConnectionImpl connection) { // The first message must be "initialize" String method = message.getString("method"); if (!INITIALIZE.equals(method)) { - LOG.errorf("The first message from the client must be \"initialize\": %s", method); - ctx.fail(400); + responder.badRequest("The first message from the client must be \"initialize\": %s", method); return; } Object id = message.getValue("id"); - JsonObject params = message.getJsonObject("params"); if (params == null) { - LOG.error("Required params not found"); - ctx.fail(400); + responder.badRequest("Initialization params not found"); return; } - // TODO schema validation + // TODO schema validation? if (connection.initialize(decodeInitializeRequest(params))) { // The server MUST respond with its own capabilities and information - setJsonContentType(ctx); - ctx.end(newResult(id, serverInfo()).encode()); + responder.sucess(newResult(id, serverInfo)); } else { - ctx.fail(400); + responder.error("Unable to initialize connection [connectionId: %s]", connection.id()); } } - private void initializing(JsonObject message, RoutingContext ctx, McpConnectionImpl connection) { + private void initializing(JsonObject message, Responder responder, McpConnectionImpl connection) { String method = message.getString("method"); if (NOTIFICATIONS_INITIALIZED.equals(method)) { if (connection.initialized()) { + responder.sucess(); LOG.infof("Client successfully initialized [%s]", connection.id()); - ctx.end(); } } else if (PING.equals(method)) { - ping(message, ctx); + ping(message, responder); } else { - LOG.infof("Client not initialized yet [%s]", connection.id()); - ctx.fail(400); + responder.badRequest("Client not initialized yet [%s]", connection.id()); } } @@ -122,38 +128,40 @@ private void initializing(JsonObject message, RoutingContext ctx, McpConnectionI private static final String PROMPTS_GET = "prompts/get"; 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 RESOURCES_READ = "resources/read"; private static final String PING = "ping"; // non-standard messages private static final String Q_CLOSE = "q/close"; - private void operation(JsonObject message, RoutingContext ctx, McpConnectionImpl connection) { + private void operation(JsonObject message, Responder responder, McpConnectionImpl connection) { String method = message.getString("method"); switch (method) { - case PROMPTS_LIST -> promptHandler.promptsList(message, ctx); - case PROMPTS_GET -> promptHandler.promptsGet(message, ctx, connection); - case TOOLS_LIST -> toolHandler.toolsList(message, ctx); - case TOOLS_CALL -> toolHandler.toolsCall(message, ctx, connection); - case PING -> ping(message, ctx); - case Q_CLOSE -> close(ctx, connection); + case PROMPTS_LIST -> promptHandler.promptsList(message, responder); + case PROMPTS_GET -> promptHandler.promptsGet(message, responder, connection); + case TOOLS_LIST -> toolHandler.toolsList(message, responder); + case TOOLS_CALL -> toolHandler.toolsCall(message, responder, connection); + case PING -> ping(message, responder); + case RESOURCES_LIST -> resourceHandler.resourcesList(message, responder); + case RESOURCES_READ -> resourceHandler.resourcesRead(message, responder, connection); + case Q_CLOSE -> close(responder, connection); default -> throw new IllegalArgumentException("Unsupported method: " + method); } } - private void ping(JsonObject message, RoutingContext ctx) { + private void ping(JsonObject message, Responder responder) { // https://spec.modelcontextprotocol.io/specification/basic/utilities/ping/ Object id = message.getValue("id"); LOG.infof("Ping [id: %s]", id); - setJsonContentType(ctx); - ctx.end(newResult(id, new JsonObject()).encode()); + responder.sucess(newResult(id, new JsonObject())); } - private void close(RoutingContext ctx, McpConnectionImpl connection) { + private void close(Responder responder, McpConnectionImpl connection) { if (connectionManager.remove(connection.id())) { LOG.infof("Connection %s closed", connection.id()); - ctx.end(); + responder.sucess(); } else { - LOG.errorf("Unable to close connection %s", connection.id()); - ctx.fail(400); + responder.badRequest("Unable to obain connection to be closed: %s", connection.id()); } } @@ -184,7 +192,8 @@ static void setJsonContentType(RoutingContext ctx) { ctx.response().putHeader(HttpHeaders.CONTENT_TYPE, "application/json"); } - private Map serverInfo() { + private Map serverInfo(PromptManager promptManager, ToolManager toolManager, + ResourceManager resourceManager) { Map info = new HashMap<>(); info.put("protocolVersion", "2024-11-05"); @@ -194,7 +203,61 @@ private Map serverInfo() { .orElse(ConfigProvider.getConfig().getOptionalValue("quarkus.application.version", String.class).orElse("N/A")); info.put("serverInfo", Map.of("name", serverName, "version", serverVersion)); - info.put("capabilities", Map.of("prompts", Map.of(), "tools", Map.of())); + Map> capabilities = new HashMap<>(); + if (!promptManager.isEmpty()) { + capabilities.put("prompts", Map.of()); + } + if (!toolManager.isEmpty()) { + capabilities.put("tools", Map.of()); + } + if (!resourceManager.isEmpty()) { + capabilities.put("resources", Map.of()); + } + info.put("capabilities", capabilities); return info; } + + class Responder { + + final RoutingContext ctx; + final TrafficLogger trafficLogger; + + Responder(TrafficLogger trafficLogger, RoutingContext ctx) { + this.trafficLogger = trafficLogger; + this.ctx = ctx; + } + + void sucess() { + ctx.end(); + } + + void sucess(JsonObject message) { + if (trafficLogger != null) { + trafficLogger.messageSent(message); + } + setJsonContentType(ctx); + ctx.end(message.toBuffer()); + } + + void badRequest(String logMessage, Object... params) { + LOG.errorf(logMessage, params); + ctx.fail(400); + } + + void badRequest(Throwable throwable, String logMessage, Object... params) { + LOG.errorf(throwable, logMessage, params); + ctx.fail(400); + } + + void error(String logMessage, Object... params) { + LOG.errorf(logMessage, params); + ctx.fail(500); + } + + void failure(int statusCode, String logMessage, Object... params) { + LOG.errorf(logMessage, params); + ctx.fail(statusCode); + } + + } } diff --git a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/McpMetadata.java b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/McpMetadata.java index d8ebfe0..f2c9fa7 100644 --- a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/McpMetadata.java +++ b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/McpMetadata.java @@ -3,6 +3,7 @@ import java.util.List; import io.quarkiverse.mcp.server.PromptResponse; +import io.quarkiverse.mcp.server.ResourceResponse; import io.quarkiverse.mcp.server.ToolResponse; public interface McpMetadata { @@ -11,4 +12,6 @@ public interface McpMetadata { List> tools(); + List> resources(); + } diff --git a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/McpServerRecorder.java b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/McpServerRecorder.java index a6355ac..6c86a60 100644 --- a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/McpServerRecorder.java +++ b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/McpServerRecorder.java @@ -8,6 +8,7 @@ import org.jboss.logging.Logger; +import io.quarkiverse.mcp.server.runtime.config.McpRuntimeConfig; import io.quarkus.arc.Arc; import io.quarkus.arc.ArcContainer; import io.quarkus.runtime.annotations.Recorder; @@ -25,9 +26,9 @@ public class McpServerRecorder { private static final Logger LOG = Logger.getLogger(McpServerRecorder.class); - private final McpBuildTimeConfig config; + private final McpRuntimeConfig config; - public McpServerRecorder(McpBuildTimeConfig config) { + public McpServerRecorder(McpRuntimeConfig config) { this.config = config; } @@ -108,7 +109,10 @@ public void accept(Route route) { } public Handler createMessagesEndpointHandler() { - return new McpMessagesHandler(Arc.container().instance(ConnectionManager.class).get(), config); + ArcContainer container = Arc.container(); + return new McpMessagesHandler(config, container.instance(ConnectionManager.class).get(), + container.instance(PromptManager.class).get(), container.instance(ToolManager.class).get(), + container.instance(ResourceManager.class).get()); } } diff --git a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/PromptManager.java b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/PromptManager.java index 1f3b977..fd19ce0 100644 --- a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/PromptManager.java +++ b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/PromptManager.java @@ -1,6 +1,5 @@ package io.quarkiverse.mcp.server.runtime; -import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -28,12 +27,7 @@ protected FeatureMetadata getMetadata(String name) { return prompts.get(name); } - /** - * - * @return the list of prompts sorted by name asc - */ - public List list() { - return prompts.values().stream().map(FeatureMetadata::info).sorted(Comparator.comparing(FeatureMethodInfo::name)) - .toList(); + public List> list() { + return prompts.values().stream().sorted().toList(); } } diff --git a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/PromptMessageHandler.java b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/PromptMessageHandler.java index 4da5446..84f143f 100644 --- a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/PromptMessageHandler.java +++ b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/PromptMessageHandler.java @@ -1,45 +1,46 @@ package io.quarkiverse.mcp.server.runtime; import static io.quarkiverse.mcp.server.runtime.McpMessagesHandler.newResult; -import static io.quarkiverse.mcp.server.runtime.McpMessagesHandler.setJsonContentType; - -import java.util.List; import org.jboss.logging.Logger; import io.quarkiverse.mcp.server.PromptResponse; -import io.quarkus.arc.Arc; +import io.quarkiverse.mcp.server.runtime.McpMessagesHandler.Responder; import io.vertx.core.AsyncResult; import io.vertx.core.Future; import io.vertx.core.Handler; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; -import io.vertx.ext.web.RoutingContext; -public class PromptMessageHandler { +class PromptMessageHandler { private static final Logger LOG = Logger.getLogger(PromptMessageHandler.class); - void promptsList(JsonObject message, RoutingContext ctx) { + private final PromptManager promptManager; + + PromptMessageHandler(PromptManager promptManager) { + this.promptManager = promptManager; + } + + void promptsList(JsonObject message, Responder responder) { Object id = message.getValue("id"); LOG.infof("List prompts [id: %s]", id); - PromptManager promptManager = Arc.container().instance(PromptManager.class).get(); - List prompts = promptManager.list(); - setJsonContentType(ctx); - ctx.end(newResult(id, new JsonObject() - .put("prompts", new JsonArray(prompts))).encode()); + JsonArray prompts = new JsonArray(); + for (FeatureMetadata resource : promptManager.list()) { + prompts.add(resource.asJson()); + } + responder.sucess(newResult(id, new JsonObject() + .put("prompts", prompts))); } - void promptsGet(JsonObject message, RoutingContext ctx, McpConnectionImpl connection) { + void promptsGet(JsonObject message, Responder responder, McpConnectionImpl connection) { Object id = message.getValue("id"); JsonObject params = message.getJsonObject("params"); String promptName = params.getString("name"); LOG.infof("Get prompt %s [id: %s]", promptName, id); - setJsonContentType(ctx); ArgumentProviders argProviders = new ArgumentProviders(params.getJsonObject("arguments").getMap(), connection, id); - PromptManager promptManager = Arc.container().instance(PromptManager.class).get(); Future fu = promptManager.get(promptName, argProviders); fu.onComplete(new Handler>() { @Override @@ -51,10 +52,9 @@ public void handle(AsyncResult ar) { result.put("description", promptResponse.description()); } result.put("messages", promptResponse.messages()); - ctx.end(newResult(id, result).encode()); + responder.sucess(newResult(id, result)); } else { - LOG.error("Unable to obtain prompt", ar.cause()); - ctx.fail(500); + responder.badRequest(ar.cause(), "Unable to obtain prompt %s", promptName); } } }); diff --git a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResourceManager.java b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResourceManager.java new file mode 100644 index 0000000..01bbab0 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResourceManager.java @@ -0,0 +1,38 @@ +package io.quarkiverse.mcp.server.runtime; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +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 ResourceManager extends FeatureManager { + + final Map> resources; + + ResourceManager(McpMetadata metadata, Vertx vertx, ObjectMapper mapper) { + super(vertx, mapper); + this.resources = metadata.resources().stream().collect(Collectors.toMap(m -> m.info().uri(), Function.identity())); + } + + @Override + protected FeatureMetadata getMetadata(String identifier) { + return resources.get(identifier); + } + + /** + * + * @return the list of resources sorted by name asc + */ + public List> list() { + return resources.values().stream().sorted().toList(); + } + +} diff --git a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResourceMessageHandler.java b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResourceMessageHandler.java new file mode 100644 index 0000000..9b3e422 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResourceMessageHandler.java @@ -0,0 +1,64 @@ +package io.quarkiverse.mcp.server.runtime; + +import static io.quarkiverse.mcp.server.runtime.McpMessagesHandler.newResult; + +import java.util.Map; + +import org.jboss.logging.Logger; + +import io.quarkiverse.mcp.server.ResourceResponse; +import io.quarkiverse.mcp.server.runtime.McpMessagesHandler.Responder; +import io.vertx.core.AsyncResult; +import io.vertx.core.Future; +import io.vertx.core.Handler; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; + +class ResourceMessageHandler { + + private static final Logger LOG = Logger.getLogger(ResourceMessageHandler.class); + + private final ResourceManager resourceManager; + + ResourceMessageHandler(ResourceManager resourceManager) { + this.resourceManager = resourceManager; + } + + void resourcesList(JsonObject message, Responder responder) { + Object id = message.getValue("id"); + LOG.infof("List resources [id: %s]", id); + JsonArray resources = new JsonArray(); + for (FeatureMetadata resource : resourceManager.list()) { + resources.add(resource.asJson()); + } + responder.sucess(newResult(id, new JsonObject() + .put("resources", resources))); + } + + void resourcesRead(JsonObject message, Responder responder, McpConnectionImpl connection) { + Object id = message.getValue("id"); + JsonObject params = message.getJsonObject("params"); + String resourceUri = params.getString("uri"); + if (resourceUri == null) { + responder.badRequest("Resource URI not defined"); + return; + } + LOG.infof("Read resource %s [id: %s]", resourceUri, id); + + ArgumentProviders argProviders = new ArgumentProviders(Map.of("uri", resourceUri), connection, id); + + Future fu = resourceManager.get(resourceUri, argProviders); + fu.onComplete(new Handler>() { + @Override + public void handle(AsyncResult ar) { + if (ar.succeeded()) { + ResourceResponse resourceResponse = ar.result(); + responder.sucess(newResult(id, resourceResponse)); + } else { + responder.badRequest(ar.cause(), "Unable to read resource %s", resourceUri); + } + } + }); + } + +} diff --git a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResultMappers.java b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResultMappers.java index 7db3c2d..c2decdb 100644 --- a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResultMappers.java +++ b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ResultMappers.java @@ -6,6 +6,8 @@ import io.quarkiverse.mcp.server.Content; import io.quarkiverse.mcp.server.PromptMessage; import io.quarkiverse.mcp.server.PromptResponse; +import io.quarkiverse.mcp.server.ResourceContents; +import io.quarkiverse.mcp.server.ResourceResponse; import io.quarkiverse.mcp.server.ToolResponse; import io.smallrye.mutiny.Uni; @@ -32,12 +34,24 @@ public class ResultMappers { public static final Function> TOOL_CONTENT = content -> Uni.createFrom() .item(ToolResponse.success(content)); - public static final Function, Uni> TOOL_LIST_CONTENT = content -> Uni.createFrom() - .item(ToolResponse.success(content)); + public static final Function, Uni> TOOL_LIST_CONTENT = list -> Uni.createFrom() + .item(ToolResponse.success(list)); public static final Function, Uni> TOOL_UNI_CONTENT = uni -> uni.map(c -> ToolResponse.success(c)); public static final Function>, Uni> TOOL_UNI_LIST_CONTENT = uni -> uni .map(l -> ToolResponse.success(l)); + public static final Function> RESOURCE_CONTENT = content -> Uni.createFrom() + .item(new ResourceResponse(List.of(content))); + + public static final Function, Uni> RESOURCE_LIST_CONTENT = list -> Uni.createFrom() + .item(new ResourceResponse(list)); + + public static final Function, Uni> RESOURCE_UNI_CONTENT = uni -> uni + .map(c -> new ResourceResponse(List.of(c))); + + public static final Function>, Uni> RESOURCE_UNI_LIST_CONTENT = uni -> uni + .map(l -> new ResourceResponse(l)); + } diff --git a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ToolManager.java b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ToolManager.java index f4134a5..90c6bce 100644 --- a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ToolManager.java +++ b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ToolManager.java @@ -1,6 +1,5 @@ package io.quarkiverse.mcp.server.runtime; -import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -28,13 +27,8 @@ protected FeatureMetadata getMetadata(String name) { return tools.get(name); } - /** - * - * @return the list of tools sorted by name asc - */ - public List list() { - return tools.values().stream().map(FeatureMetadata::info).sorted(Comparator.comparing(FeatureMethodInfo::name)) - .toList(); + public List> list() { + return tools.values().stream().sorted().toList(); } } diff --git a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ToolMessageHandler.java b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ToolMessageHandler.java index f2badda..214eb3c 100644 --- a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ToolMessageHandler.java +++ b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/ToolMessageHandler.java @@ -1,10 +1,8 @@ package io.quarkiverse.mcp.server.runtime; import static io.quarkiverse.mcp.server.runtime.McpMessagesHandler.newResult; -import static io.quarkiverse.mcp.server.runtime.McpMessagesHandler.setJsonContentType; import java.lang.reflect.Type; -import java.util.List; import org.jboss.logging.Logger; @@ -16,40 +14,37 @@ import com.github.victools.jsonschema.generator.SchemaVersion; import io.quarkiverse.mcp.server.ToolResponse; -import io.quarkus.arc.Arc; +import io.quarkiverse.mcp.server.runtime.McpMessagesHandler.Responder; import io.vertx.core.AsyncResult; import io.vertx.core.Future; import io.vertx.core.Handler; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; -import io.vertx.ext.web.RoutingContext; -public class ToolMessageHandler { +class ToolMessageHandler { private static final Logger LOG = Logger.getLogger(ToolMessageHandler.class); + private final ToolManager toolManager; + private final SchemaGenerator schemaGenerator; - ToolMessageHandler() { + ToolMessageHandler(ToolManager toolManager) { + this.toolManager = toolManager; this.schemaGenerator = new SchemaGenerator( new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON).build()); } - void toolsList(JsonObject message, RoutingContext ctx) { + void toolsList(JsonObject message, Responder responder) { Object id = message.getValue("id"); LOG.infof("List tools [id: %s]", id); - ToolManager toolManager = Arc.container().instance(ToolManager.class).get(); - setJsonContentType(ctx); - - List tools = toolManager.list(); - JsonArray toolsArray = new JsonArray(); - for (FeatureMethodInfo info : tools) { - JsonObject tool = new JsonObject() - .put("name", info.name()) - .put("description", info.description()); + + JsonArray tools = new JsonArray(); + for (FeatureMetadata toolMetadata : toolManager.list()) { + JsonObject tool = toolMetadata.asJson(); JsonObject properties = new JsonObject(); JsonArray required = new JsonArray(); - for (FeatureArgument a : info.arguments()) { + for (FeatureArgument a : toolMetadata.info().arguments()) { properties.put(a.name(), generateSchema(a.type(), a)); if (a.required()) { required.add(a.name()); @@ -59,11 +54,10 @@ void toolsList(JsonObject message, RoutingContext ctx) { .put("type", "object") .put("properties", properties) .put("required", required)); - toolsArray.add(tool); + tools.add(tool); } - - ctx.end(newResult(id, new JsonObject() - .put("tools", toolsArray)).encode()); + responder.sucess(newResult(id, new JsonObject() + .put("tools", tools))); } private Object generateSchema(Type type, FeatureArgument argument) { @@ -78,26 +72,23 @@ private Object generateSchema(Type type, FeatureArgument argument) { return jsonNode; } - void toolsCall(JsonObject message, RoutingContext ctx, McpConnectionImpl connection) { + void toolsCall(JsonObject message, Responder responder, McpConnectionImpl connection) { Object id = message.getValue("id"); JsonObject params = message.getJsonObject("params"); String toolName = params.getString("name"); LOG.infof("Call tool %s [id: %s]", toolName, id); ArgumentProviders argProviders = new ArgumentProviders(params.getJsonObject("arguments").getMap(), connection, id); - setJsonContentType(ctx); - ToolManager toolManager = Arc.container().instance(ToolManager.class).get(); Future fu = toolManager.get(toolName, argProviders); fu.onComplete(new Handler>() { @Override public void handle(AsyncResult ar) { if (ar.succeeded()) { ToolResponse toolResponse = ar.result(); - ctx.end(newResult(id, toolResponse).encode()); + responder.sucess(newResult(id, toolResponse)); } else { - LOG.error("Unable to call tool " + toolName, ar.cause()); - ctx.fail(500); + responder.badRequest(ar.cause(), "Unable to call tool %s", toolName); } } }); diff --git a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/TrafficLogger.java b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/TrafficLogger.java new file mode 100644 index 0000000..7195a09 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/TrafficLogger.java @@ -0,0 +1,40 @@ +package io.quarkiverse.mcp.server.runtime; + +import org.jboss.logging.Logger; + +import io.vertx.core.json.JsonObject; + +class TrafficLogger { + + private static final Logger LOG = Logger.getLogger("io.quarkus.mcp.server.traffic"); + + private final int textPayloadLimit; + + TrafficLogger(int textPayloadLimit) { + this.textPayloadLimit = textPayloadLimit; + } + + void messageReceived(JsonObject message) { + if (LOG.isDebugEnabled()) { + LOG.debugf("JSON message received:\n\n%s", messageToString(message)); + } + } + + void messageSent(JsonObject message) { + if (LOG.isDebugEnabled()) { + LOG.debugf("JSON message sent:\n\n%s", messageToString(message)); + } + } + + private String messageToString(JsonObject message) { + String encoded = message.encodePrettily(); + if (encoded == null || encoded.isBlank()) { + return "n/a"; + } else if (textPayloadLimit < 0 || encoded.length() <= textPayloadLimit) { + return encoded; + } else { + return encoded.substring(0, encoded.length()) + "..."; + } + } + +} diff --git a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/config/McpBuildTimeConfig.java b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/config/McpBuildTimeConfig.java new file mode 100644 index 0000000..a7dcc02 --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/config/McpBuildTimeConfig.java @@ -0,0 +1,20 @@ +package io.quarkiverse.mcp.server.runtime.config; + +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +@ConfigMapping(prefix = "quarkus.mcp.server") +@ConfigRoot(phase = ConfigPhase.BUILD_AND_RUN_TIME_FIXED) +public interface McpBuildTimeConfig { + + /** + * The root path. + * + * @asciidoclet + */ + @WithDefault("/mcp") + String rootPath(); + +} diff --git a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/McpBuildTimeConfig.java b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/config/McpRuntimeConfig.java similarity index 64% rename from runtime/src/main/java/io/quarkiverse/mcp/server/runtime/McpBuildTimeConfig.java rename to runtime/src/main/java/io/quarkiverse/mcp/server/runtime/config/McpRuntimeConfig.java index 02df5b8..7d3e22c 100644 --- a/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/McpBuildTimeConfig.java +++ b/runtime/src/main/java/io/quarkiverse/mcp/server/runtime/config/McpRuntimeConfig.java @@ -1,4 +1,4 @@ -package io.quarkiverse.mcp.server.runtime; +package io.quarkiverse.mcp.server.runtime.config; import java.util.Optional; @@ -7,17 +7,9 @@ import io.smallrye.config.ConfigMapping; import io.smallrye.config.WithDefault; -@ConfigMapping(prefix = "quarkus.mcp-server") -@ConfigRoot(phase = ConfigPhase.BUILD_AND_RUN_TIME_FIXED) -public interface McpBuildTimeConfig { - - /** - * The root path. - * - * @asciidoclet - */ - @WithDefault("/mcp") - String rootPath(); +@ConfigMapping(prefix = "quarkus.mcp.server") +@ConfigRoot(phase = ConfigPhase.RUN_TIME) +public interface McpRuntimeConfig { /** * The server info is included in the response to an `initialize` request as defined by the @@ -27,6 +19,11 @@ public interface McpBuildTimeConfig { */ ServerInfo serverInfo(); + /** + * Traffic logging config. + */ + TrafficLogging trafficLogging(); + public interface ServerInfo { /** @@ -49,4 +46,20 @@ public interface ServerInfo { } + public interface TrafficLogging { + + /** + * If set to true then JSON messages received/sent are logged if the {@code DEBUG} level is enabled for the + * logger {@code io.quarkus.mcp.server.traffic}. + */ + @WithDefault("false") + public boolean enabled(); + + /** + * The number of characters of a text message which will be logged if traffic logging is enabled. + */ + @WithDefault("100") + public int textLimit(); + } + }