From 9279688c0f137fea4c41da9510389c5fc9ac1c5d Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 21 Mar 2026 11:54:55 -0300 Subject: [PATCH 01/37] feat(mcp): implement zero-reflection APT dispatcher for MCP servers Introduces compile-time annotation processing for the Model Context Protocol (MCP), generating highly optimized, reflection-free dispatchers that bridge untyped JSON-RPC arguments to strongly-typed Jooby controllers. Key implementations: - Add `@McpServer`, `@McpTool`, `@McpPrompt`, `@McpResource`, and `@McpCompletion` annotations for defining MCP capabilities. - Add `@McpParam` to customize schema parameter names and provide LLM descriptions (with planned Javadoc fallback). - Update `MvcRoute` to classify MCP components during construction and isolate them into a dedicated `mcpRoutes` list in `MvcRouter` to prevent REST generator conflicts. - Define `McpService` interface enforcing a strict execution contract with `McpSyncServerExchange` for Context resolution. - Implement `getMcpSourceCode` in `MvcRouter` to generate `*McpServer_` classes featuring: - Zero-reflection `switch/case` routing for tools, prompts, resources, templates, and completions. - Safe extraction and primitive casting from untyped argument maps. - Strict Jackson 3 (`tools.jackson`) registry lookup for complex POJO conversions. - Native Jooby dependency injection support using the existing `constructors()` AST utility. - Interface compliance by throwing `UnsupportedOperationException` for unused MCP capabilities. --- .../io/jooby/annotation/McpCompletion.java | 25 + .../java/io/jooby/annotation/McpParam.java | 38 ++ .../java/io/jooby/annotation/McpPrompt.java | 30 ++ .../java/io/jooby/annotation/McpResource.java | 48 ++ .../java/io/jooby/annotation/McpServer.java | 23 + .../java/io/jooby/annotation/McpTool.java | 30 ++ modules/jooby-apt/pom.xml | 14 +- .../java/io/jooby/apt/JoobyProcessor.java | 25 +- .../io/jooby/internal/apt/HttpMethod.java | 8 +- .../io/jooby/internal/apt/MvcParameter.java | 17 + .../java/io/jooby/internal/apt/MvcRoute.java | 74 +++ .../java/io/jooby/internal/apt/MvcRouter.java | 435 +++++++++++++++++- .../src/test/java/tests/i3804/Issue3804.java | 1 + .../test/java/tests/i3830/ExampleServer.java | 47 ++ .../java/tests/i3830/ExampleServerMcp_.java | 8 + .../src/test/java/tests/i3830/Issue3830.java | 21 + modules/jooby-mcp/pom.xml | 26 ++ .../main/java/io/jooby/mcp/McpService.java | 41 ++ .../src/test/java/issues/i2968/C2968_.java | 32 -- modules/pom.xml | 1 + pom.xml | 7 + 21 files changed, 911 insertions(+), 40 deletions(-) create mode 100644 jooby/src/main/java/io/jooby/annotation/McpCompletion.java create mode 100644 jooby/src/main/java/io/jooby/annotation/McpParam.java create mode 100644 jooby/src/main/java/io/jooby/annotation/McpPrompt.java create mode 100644 jooby/src/main/java/io/jooby/annotation/McpResource.java create mode 100644 jooby/src/main/java/io/jooby/annotation/McpServer.java create mode 100644 jooby/src/main/java/io/jooby/annotation/McpTool.java create mode 100644 modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java create mode 100644 modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java create mode 100644 modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java create mode 100644 modules/jooby-mcp/pom.xml create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java delete mode 100644 modules/jooby-openapi/src/test/java/issues/i2968/C2968_.java diff --git a/jooby/src/main/java/io/jooby/annotation/McpCompletion.java b/jooby/src/main/java/io/jooby/annotation/McpCompletion.java new file mode 100644 index 0000000000..06acf00532 --- /dev/null +++ b/jooby/src/main/java/io/jooby/annotation/McpCompletion.java @@ -0,0 +1,25 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Marks a method as an MCP Completion provider. */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface McpCompletion { + /** + * The identifier of the reference. This is either the Prompt name (e.g., "code_review") or the + * Resource Template URI (e.g., "file:///project/{name}"). + */ + String ref(); + + /** The name of the argument or template variable being completed. */ + String arg(); +} diff --git a/jooby/src/main/java/io/jooby/annotation/McpParam.java b/jooby/src/main/java/io/jooby/annotation/McpParam.java new file mode 100644 index 0000000000..873b4c1c51 --- /dev/null +++ b/jooby/src/main/java/io/jooby/annotation/McpParam.java @@ -0,0 +1,38 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Provides metadata for an MCP Tool or Prompt parameter. */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface McpParam { + /** + * The name of the parameter in the MCP schema. If empty, the Java variable name is used. + * + * @return Parameter name. + */ + String name() default ""; + + /** + * A description of the parameter for the LLM. If empty, it falls back to the @param tag in the + * method's Javadoc. + * + * @return Parameter description. + */ + String description() default ""; + + /** + * Whether this parameter is required. + * + * @return True if required, false otherwise. + */ + boolean required() default true; +} diff --git a/jooby/src/main/java/io/jooby/annotation/McpPrompt.java b/jooby/src/main/java/io/jooby/annotation/McpPrompt.java new file mode 100644 index 0000000000..7d653c3c91 --- /dev/null +++ b/jooby/src/main/java/io/jooby/annotation/McpPrompt.java @@ -0,0 +1,30 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Exposes a method as an MCP Prompt. */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface McpPrompt { + /** + * The name of the prompt. If empty, the method name is used. + * + * @return Prompt name. + */ + String name() default ""; + + /** + * A description of what the prompt provides. + * + * @return Prompt description. + */ + String description() default ""; +} diff --git a/jooby/src/main/java/io/jooby/annotation/McpResource.java b/jooby/src/main/java/io/jooby/annotation/McpResource.java new file mode 100644 index 0000000000..48c1a42670 --- /dev/null +++ b/jooby/src/main/java/io/jooby/annotation/McpResource.java @@ -0,0 +1,48 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Exposes a method as an MCP Resource or Resource Template. + * + *

If the URI contains path variables (e.g., "file:///{dir}/{filename}"), it will be treated as a + * ResourceTemplate. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface McpResource { + /** + * The exact URI or URI template for the resource. + * + * @return The resource URI. + */ + String value(); + + /** + * The name of the resource. + * + * @return Resource name. + */ + String name() default ""; + + /** + * A description of the resource. + * + * @return Resource description. + */ + String description() default ""; + + /** + * The MIME type of the resource (e.g., "text/plain", "application/json"). * @return The MIME + * type. + */ + String mimeType() default ""; +} diff --git a/jooby/src/main/java/io/jooby/annotation/McpServer.java b/jooby/src/main/java/io/jooby/annotation/McpServer.java new file mode 100644 index 0000000000..080bb736bb --- /dev/null +++ b/jooby/src/main/java/io/jooby/annotation/McpServer.java @@ -0,0 +1,23 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Marks a class as an MCP (Model Context Protocol) Server. */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface McpServer { + /** + * The server key used to look up configuration in application.conf. Defaults to "default". + * + * @return The server configuration key. + */ + String value() default "default"; +} diff --git a/jooby/src/main/java/io/jooby/annotation/McpTool.java b/jooby/src/main/java/io/jooby/annotation/McpTool.java new file mode 100644 index 0000000000..c5f12c92de --- /dev/null +++ b/jooby/src/main/java/io/jooby/annotation/McpTool.java @@ -0,0 +1,30 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Exposes a method as an MCP Tool. */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface McpTool { + /** + * The name of the tool. If empty, the method name is used. + * + * @return Tool name. + */ + String name() default ""; + + /** + * A description of what the tool does. Highly recommended for LLM usage. + * + * @return Tool description. + */ + String description() default ""; +} diff --git a/modules/jooby-apt/pom.xml b/modules/jooby-apt/pom.xml index 4f98b591cf..6fb1fb6580 100644 --- a/modules/jooby-apt/pom.xml +++ b/modules/jooby-apt/pom.xml @@ -25,6 +25,13 @@ test + + io.jooby + jooby-mcp + ${jooby.version} + test + + io.jooby jooby-jackson3 @@ -44,7 +51,6 @@ test - com.google.testing.compile compile-testing @@ -58,6 +64,12 @@ test + + io.modelcontextprotocol.sdk + mcp-core + test + + com.google.truth truth diff --git a/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java b/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java index 0426a3b9d5..3ec285b672 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java +++ b/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java @@ -38,7 +38,7 @@ ROUTER_SUFFIX, SKIP_ATTRIBUTE_ANNOTATIONS }) -@SupportedSourceVersion(SourceVersion.RELEASE_17) +@SupportedSourceVersion(SourceVersion.RELEASE_21) public class JoobyProcessor extends AbstractProcessor { /** Available options. */ public interface Options { @@ -193,6 +193,24 @@ public boolean process(Set annotations, RoundEnvironment router.getTargetType()); } } + // 3. Generate MCP Server File (e.g., WeatherServerMcp_.java) + if (router.hasMcpRoutes()) { + var mcpSource = router.getMcpSourceCode(null); + if (mcpSource != null) { + var sourceLocation = router.getMcpGeneratedFilename(); + var generatedType = router.getMcpGeneratedType(); + onGeneratedSource(generatedType, toJavaFileObject(sourceLocation, mcpSource)); + + context.debug("mcp router %s: %s", router.getTargetType(), generatedType); + + writeSource( + router.isKt(), + generatedType, + sourceLocation, + mcpSource, + router.getTargetType()); + } + } } catch (IOException cause) { throw new RuntimeException("Unable to generate: " + router.getTargetType(), cause); @@ -401,6 +419,11 @@ public Set getSupportedAnnotationTypes() { var supportedTypes = new HashSet(); supportedTypes.addAll(HttpPath.PATH.getAnnotations()); supportedTypes.addAll(HttpMethod.annotations()); + // Add MCP Annotations + supportedTypes.add("io.jooby.annotation.McpTool"); + supportedTypes.add("io.jooby.annotation.McpPrompt"); + supportedTypes.add("io.jooby.annotation.McpResource"); + supportedTypes.add("io.jooby.annotation.McpServer"); return supportedTypes; } diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java index f7d2026edc..037005135a 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java @@ -34,7 +34,13 @@ public enum HttpMethod implements AnnotationSupport { "io.jooby.annotation.Trpc", "io.jooby.annotation.Trpc.Mutation", "io.jooby.annotation.Trpc.Query")), - JSON_RPC(List.of("io.jooby.annotation.JsonRpc")); + JSON_RPC(List.of("io.jooby.annotation.JsonRpc")), + MCP( + List.of( + "io.jooby.annotation.McpTool", + "io.jooby.annotation.McpPrompt", + "io.jooby.annotation.McpResource", + "io.jooby.annotation.McpServer")); private final List annotations; diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java index 6e8038a90d..7c68b84c57 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java @@ -41,6 +41,23 @@ public String getName() { return parameter.getSimpleName().toString(); } + public String getMcpName() { + var annotation = annotations.get("io.jooby.annotation.McpParam"); + if (annotation != null) { + var customName = + io.jooby.internal.apt.AnnotationSupport.findAnnotationValue(annotation, "name"::equals) + .stream() + .findFirst() + .orElse(""); + + if (!customName.isEmpty()) { + return customName; + } + } + // Fallback to the actual Java parameter name + return getName(); + } + public String generateMapping(boolean kt) { var strategy = annotations.entrySet().stream() diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java index 97024e3337..4f2d28a90d 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java @@ -36,6 +36,11 @@ public class MvcRoute { private boolean isTrpc = false; private boolean isJsonRpc = false; + private boolean isMcpTool = false; + private boolean isMcpPrompt = false; + private boolean isMcpResource = false; + private boolean isMcpResourceTemplate = false; + private boolean isMcpCompletion = false; private HttpMethod resolvedTrpcMethod = null; public MvcRoute(MvcContext context, MvcRouter router, ExecutableElement method) { @@ -51,6 +56,7 @@ public MvcRoute(MvcContext context, MvcRouter router, ExecutableElement method) this.returnType = new TypeDefinition( context.getProcessingEnvironment().getTypeUtils(), method.getReturnType()); + this.checkMcpAnnotations(); } public MvcRoute(MvcContext context, MvcRouter router, MvcRoute route) { @@ -64,6 +70,14 @@ public MvcRoute(MvcContext context, MvcRouter router, MvcRoute route) { new TypeDefinition( context.getProcessingEnvironment().getTypeUtils(), method.getReturnType()); this.suspendFun = route.suspendFun; + // from here + this.isJsonRpc = route.isJsonRpc; + this.isTrpc = route.isTrpc; + this.isMcpTool = route.isMcpTool; + this.isMcpPrompt = route.isMcpPrompt; + this.isMcpResource = route.isMcpResource; + this.isMcpResourceTemplate = route.isMcpResourceTemplate; + this.isMcpCompletion = route.isMcpCompletion; route.annotationMap.keySet().forEach(this::addHttpMethod); } @@ -87,6 +101,66 @@ public boolean isTrpc() { return isTrpc; } + // Inside the constructor or addHttpMethod equivalent, scan for the annotations: + public MvcRoute checkMcpAnnotations() { + if (AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.McpTool") + != null) { + this.isMcpTool = true; + } + if (AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.McpPrompt") + != null) { + this.isMcpPrompt = true; + } + if (AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.McpResource") + != null) { + this.isMcpResource = true; + } + var resourceAnno = + AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.McpResource"); + if (resourceAnno != null) { + String uri = + AnnotationSupport.findAnnotationValue(resourceAnno, "value"::equals).stream() + .findFirst() + .orElse(""); + if (uri.contains("{") && uri.contains("}")) { + this.isMcpResourceTemplate = true; + } else { + this.isMcpResource = true; + } + } + + if (AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.McpCompletion") + != null) { + this.isMcpCompletion = true; + } + return this; + } + + // Add getters + public boolean isMcpTool() { + return isMcpTool; + } + + public boolean isMcpPrompt() { + return isMcpPrompt; + } + + public boolean isMcpResource() { + return isMcpResource; + } + + public boolean isMcpResourceTemplate() { + return isMcpResourceTemplate; + } + + public boolean isMcpCompletion() { + return isMcpCompletion; + } + + public boolean isMcpRoute() { + return isMcpTool || isMcpPrompt || isMcpResource || isMcpResourceTemplate || isMcpCompletion; + } + public boolean isProjection() { if (returnType.is(Types.PROJECTED)) { return false; diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java index f3f347d105..288f847574 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java @@ -113,10 +113,6 @@ public String getGeneratedType() { return context.generateRouterName(name); } - public String getGeneratedFilename() { - return getGeneratedType().replace('.', '/') + (isKt() ? ".kt" : ".java"); - } - public MvcRouter put(TypeElement httpMethod, ExecutableElement route) { var isTrpc = HttpMethod.findByAnnotationName(httpMethod.getQualifiedName().toString()) @@ -170,7 +166,7 @@ public String getJsonRpcNamespace() { } public boolean hasRestRoutes() { - return getRoutes().stream().anyMatch(it -> !it.isJsonRpc()); + return getRoutes().stream().anyMatch(it -> !it.isJsonRpc() && !it.isMcpRoute()); } public boolean hasJsonRpcRoutes() { @@ -284,6 +280,435 @@ public String getRpcSourceCode(Boolean generateKotlin) { return generateJsonRpcService(generateKotlin == Boolean.TRUE || isKt()); } + public boolean hasMcpRoutes() { + return getRoutes().stream().anyMatch(MvcRoute::isMcpRoute); + } + + public String getMcpGeneratedType() { + return context.generateRouterName(getTargetType().getQualifiedName().toString() + "Mcp"); + } + + public String getMcpGeneratedFilename() { + return getMcpGeneratedType().replace('.', '/') + (isKt() ? ".kt" : ".java"); + } + + public String getMcpServerKey() { + var annotation = AnnotationSupport.findAnnotationByName(clazz, "io.jooby.annotation.McpServer"); + if (annotation != null) { + return AnnotationSupport.findAnnotationValue(annotation, VALUE).stream() + .findFirst() + .orElse("default"); + } + return "default"; + } + + public String getMcpSourceCode(Boolean generateKotlin) { + if (!hasMcpRoutes()) { + return null; + } + + boolean kt = generateKotlin == Boolean.TRUE || isKt(); + var buffer = new StringBuilder(); + var generateTypeName = getTargetType().getSimpleName().toString(); + var mcpClassName = context.generateRouterName(generateTypeName + "Mcp"); + var packageName = getPackageName(); + + // FIXED: Read directly from getRoutes() since we are keeping a unified map + var tools = getRoutes().stream().filter(MvcRoute::isMcpTool).toList(); + var prompts = getRoutes().stream().filter(MvcRoute::isMcpPrompt).toList(); + var resources = getRoutes().stream().filter(MvcRoute::isMcpResource).toList(); + var templates = getRoutes().stream().filter(MvcRoute::isMcpResourceTemplate).toList(); + var completions = getRoutes().stream().filter(MvcRoute::isMcpCompletion).toList(); + + buffer.append(CodeBlock.statement("package ", packageName, CodeBlock.semicolon(kt))); + buffer.append(System.lineSeparator()); + + if (kt) { + // Kotlin setup... + } else { + buffer.append( + CodeBlock.statement( + "public class ", mcpClassName, " implements io.jooby.mcp.McpService {")); + + // 1. Declare the factory field + buffer.append( + CodeBlock.statement( + CodeBlock.indent(2), + "protected java.util.function.Function factory", + CodeBlock.semicolon(kt))); + + // 2. Use the EXISTING constructors() method + buffer.append(constructors(mcpClassName, false).toString().replaceAll("(?m)^ ", "")); + + // 3. Generate the setup() method required by the constructors() output + buffer + .append(CodeBlock.indent(2)) + .append("private void setup(java.util.function.Function factory) {\n"); + buffer.append(CodeBlock.indent(4)).append("this.factory = factory;\n"); + buffer.append(CodeBlock.indent(2)).append("}\n\n"); + + // --- THE DISPATCHER GENERATORS --- + buffer.append(generateMcpDispatcher("invokeTool", tools, kt, generateTypeName)); + buffer.append(generateMcpDispatcher("invokePrompt", prompts, kt, generateTypeName)); + buffer.append(generateMcpDispatcher("readResource", resources, kt, generateTypeName)); + buffer.append(generateResourceTemplateDispatcher(templates, kt, generateTypeName)); + buffer.append(generateCompletionDispatcher(completions, kt, generateTypeName)); + + buffer.append(CodeBlock.statement("}")); + } + + return buffer.toString(); + } + + private String generateResourceTemplateDispatcher( + List templates, boolean kt, String generateTypeName) { + StringBuilder buffer = new StringBuilder(); + buffer.append(CodeBlock.indent(2)).append("@Override\n"); + buffer + .append(CodeBlock.indent(2)) + .append( + "public Object readResourceByTemplate(String templateUri, java.util.Map" + + " args, io.modelcontextprotocol.server.McpSyncServerExchange exchange) throws" + + " Exception {\n"); + + if (templates.isEmpty()) { + buffer + .append(CodeBlock.indent(4)) + .append( + "throw new UnsupportedOperationException(\"readResourceByTemplate is not supported by" + + " this server\");\n"); + buffer.append(CodeBlock.indent(2)).append("}\n\n"); + return buffer.toString(); + } + + buffer + .append(CodeBlock.indent(4)) + .append( + "io.jooby.Context ctx = (io.jooby.Context)" + + " exchange.transportContext().get(\"CTX\");\n"); + buffer + .append(CodeBlock.indent(4)) + .append(generateTypeName) + .append(" c = factory.apply(ctx);\n"); + buffer + .append(CodeBlock.indent(4)) + .append( + "tools.jackson.databind.ObjectMapper mapper =" + + " ctx.require(tools.jackson.databind.ObjectMapper.class);\n\n"); + + buffer.append(CodeBlock.indent(4)).append("switch (templateUri) {\n"); + + for (MvcRoute route : templates) { + String uriTemplate = + extractAnnotationValue(route, "io.jooby.annotation.McpResource", "value"); + buffer.append(CodeBlock.indent(6)).append("case \"").append(uriTemplate).append("\": {\n"); + + List javaParamNames = new ArrayList<>(); + for (MvcParameter param : route.getParameters(false)) { + String javaName = param.getName(); + String mcpName = param.getMcpName(); + String type = param.getType().getRawType().toString(); + javaParamNames.add(javaName); + + buffer + .append(CodeBlock.indent(8)) + .append("Object raw_") + .append(javaName) + .append(" = args != null ? args.get(\"") + .append(mcpName) + .append("\") : null;\n"); + buffer + .append(CodeBlock.indent(8)) + .append(type) + .append(" ") + .append(javaName) + .append(" = (") + .append(type) + .append(") raw_") + .append(javaName) + .append(";\n"); + } + + String methodCall = + "c." + route.getMethodName() + "(" + String.join(", ", javaParamNames) + ")"; + buffer.append(CodeBlock.indent(8)).append("return ").append(methodCall).append(";\n"); + buffer.append(CodeBlock.indent(6)).append("}\n"); + } + + buffer.append(CodeBlock.indent(6)).append("default:\n"); + buffer + .append(CodeBlock.indent(8)) + .append( + "throw new IllegalArgumentException(\"Unknown MCP Resource Template: \" +" + + " templateUri);\n"); + buffer.append(CodeBlock.indent(4)).append("}\n"); + buffer.append(CodeBlock.indent(2)).append("}\n\n"); + + return buffer.toString(); + } + + private String generateCompletionDispatcher( + List completions, boolean kt, String generateTypeName) { + StringBuilder buffer = new StringBuilder(); + buffer.append(CodeBlock.indent(2)).append("@Override\n"); + buffer + .append(CodeBlock.indent(2)) + .append( + "public Object invokeCompletion(String identifier, String argumentName, String input," + + " io.modelcontextprotocol.server.McpSyncServerExchange exchange) throws Exception" + + " {\n"); + + if (completions.isEmpty()) { + buffer + .append(CodeBlock.indent(4)) + .append( + "throw new UnsupportedOperationException(\"invokeCompletion is not supported by this" + + " server\");\n"); + buffer.append(CodeBlock.indent(2)).append("}\n\n"); + return buffer.toString(); + } + + buffer + .append(CodeBlock.indent(4)) + .append( + "io.jooby.Context ctx = (io.jooby.Context)" + + " exchange.transportContext().get(\"CTX\");\n"); + buffer + .append(CodeBlock.indent(4)) + .append(generateTypeName) + .append(" c = factory.apply(ctx);\n\n"); + + buffer + .append(CodeBlock.indent(4)) + .append("String completionKey = identifier + \"_\" + argumentName;\n"); + buffer.append(CodeBlock.indent(4)).append("switch (completionKey) {\n"); + + for (MvcRoute route : completions) { + String ref = extractAnnotationValue(route, "io.jooby.annotation.McpCompletion", "ref"); + String arg = extractAnnotationValue(route, "io.jooby.annotation.McpCompletion", "arg"); + String key = ref + "_" + arg; + + buffer.append(CodeBlock.indent(6)).append("case \"").append(key).append("\": {\n"); + buffer + .append(CodeBlock.indent(8)) + .append("return c.") + .append(route.getMethodName()) + .append("(input);\n"); + buffer.append(CodeBlock.indent(6)).append("}\n"); + } + + buffer.append(CodeBlock.indent(6)).append("default:\n"); + buffer.append(CodeBlock.indent(8)).append("return java.util.List.of();\n"); + buffer.append(CodeBlock.indent(4)).append("}\n"); + buffer.append(CodeBlock.indent(2)).append("}\n\n"); + + return buffer.toString(); + } + + private String generateMcpDispatcher( + String dispatchMethod, List routes, boolean kt, String generateTypeName) { + StringBuilder buffer = new StringBuilder(); + buffer.append(CodeBlock.indent(2)).append("@Override\n"); + buffer + .append(CodeBlock.indent(2)) + .append("public Object ") + .append(dispatchMethod) + .append( + "(String name, java.util.Map args," + + " io.modelcontextprotocol.server.McpSyncServerExchange exchange) throws Exception" + + " {\n"); + + if (routes.isEmpty()) { + buffer + .append(CodeBlock.indent(4)) + .append("throw new UnsupportedOperationException(\"") + .append(dispatchMethod) + .append(" is not supported by this server\");\n"); + buffer.append(CodeBlock.indent(2)).append("}\n\n"); + return buffer.toString(); + } + + buffer + .append(CodeBlock.indent(4)) + .append( + "io.jooby.Context ctx = (io.jooby.Context)" + + " exchange.transportContext().get(\"CTX\");\n"); + buffer + .append(CodeBlock.indent(4)) + .append(generateTypeName) + .append(" c = factory.apply(ctx);\n"); + buffer + .append(CodeBlock.indent(4)) + .append( + "tools.jackson.databind.ObjectMapper mapper =" + + " ctx.require(tools.jackson.databind.ObjectMapper.class);\n\n"); + + buffer.append(CodeBlock.indent(4)).append("switch (name) {\n"); + + for (MvcRoute route : routes) { + String annotationClass; + String attributeName; + + if (dispatchMethod.equals("invokeTool")) { + annotationClass = "io.jooby.annotation.McpTool"; + attributeName = "name"; + } else if (dispatchMethod.equals("invokePrompt")) { + annotationClass = "io.jooby.annotation.McpPrompt"; + attributeName = "name"; + } else if (dispatchMethod.equals("readResource")) { + annotationClass = "io.jooby.annotation.McpResource"; + attributeName = "value"; + } else { + throw new IllegalStateException("Unsupported dispatch method: " + dispatchMethod); + } + + String routeName = extractAnnotationValue(route, annotationClass, attributeName); + if (routeName.isEmpty()) routeName = route.getMethodName(); + + buffer.append(CodeBlock.indent(6)).append("case \"").append(routeName).append("\": {\n"); + + List javaParamNames = new ArrayList<>(); + for (MvcParameter param : route.getParameters(false)) { + String javaName = param.getName(); + String mcpName = param.getMcpName(); + String type = param.getType().getRawType().toString(); + javaParamNames.add(javaName); + + if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange")) { + buffer + .append(CodeBlock.indent(8)) + .append(type) + .append(" ") + .append(javaName) + .append(" = exchange;\n"); + continue; + } + if (type.equals("io.jooby.Context")) { + buffer + .append(CodeBlock.indent(8)) + .append(type) + .append(" ") + .append(javaName) + .append(" = ctx;\n"); + continue; + } + + buffer + .append(CodeBlock.indent(8)) + .append("Object raw_") + .append(javaName) + .append(" = args != null ? args.get(\"") + .append(mcpName) + .append("\") : null;\n"); + + switch (type) { + case "java.lang.String": + buffer + .append(CodeBlock.indent(8)) + .append(type) + .append(" ") + .append(javaName) + .append(" = (String) raw_") + .append(javaName) + .append(";\n"); + break; + case "int": + case "java.lang.Integer": + buffer + .append(CodeBlock.indent(8)) + .append(type) + .append(" ") + .append(javaName) + .append(" = raw_") + .append(javaName) + .append(" == null ? 0 : ((Number) raw_") + .append(javaName) + .append(").intValue();\n"); + break; + case "double": + case "java.lang.Double": + buffer + .append(CodeBlock.indent(8)) + .append(type) + .append(" ") + .append(javaName) + .append(" = raw_") + .append(javaName) + .append(" == null ? 0.0 : ((Number) raw_") + .append(javaName) + .append(").doubleValue();\n"); + break; + case "boolean": + case "java.lang.Boolean": + buffer + .append(CodeBlock.indent(8)) + .append(type) + .append(" ") + .append(javaName) + .append(" = raw_") + .append(javaName) + .append(" == null ? false : (Boolean) raw_") + .append(javaName) + .append(";\n"); + break; + default: + buffer + .append(CodeBlock.indent(8)) + .append(type) + .append(" ") + .append(javaName) + .append(" = raw_") + .append(javaName) + .append(" == null ? null : mapper.convertValue(raw_") + .append(javaName) + .append(", ") + .append(type) + .append(".class);\n"); + break; + } + } + + String methodCall = + "c." + route.getMethodName() + "(" + String.join(", ", javaParamNames) + ")"; + if (route.getReturnType().isVoid()) { + buffer.append(CodeBlock.indent(8)).append(methodCall).append(";\n"); + buffer.append(CodeBlock.indent(8)).append("return null;\n"); + } else { + buffer.append(CodeBlock.indent(8)).append("return ").append(methodCall).append(";\n"); + } + buffer.append(CodeBlock.indent(6)).append("}\n"); + } + + buffer.append(CodeBlock.indent(6)).append("default:\n"); + buffer + .append(CodeBlock.indent(8)) + .append("throw new IllegalArgumentException(\"Unknown MCP entity for \" + \"") + .append(dispatchMethod) + .append("\" + \": \" + name);\n"); + buffer.append(CodeBlock.indent(4)).append("}\n"); + buffer.append(CodeBlock.indent(2)).append("}\n\n"); + + return buffer.toString(); + } + + private String extractAnnotationValue(MvcRoute route, String annotationName, String attribute) { + var annotation = + io.jooby.internal.apt.AnnotationSupport.findAnnotationByName( + route.getMethod(), annotationName); + if (annotation == null) { + return ""; + } + return io.jooby.internal.apt.AnnotationSupport.findAnnotationValue( + annotation, attribute::equals) + .stream() + .findFirst() + .orElse(""); + } + private String generateJsonRpcService(boolean kt) { var buffer = new StringBuilder(); var generateTypeName = getTargetType().getSimpleName().toString(); diff --git a/modules/jooby-apt/src/test/java/tests/i3804/Issue3804.java b/modules/jooby-apt/src/test/java/tests/i3804/Issue3804.java index 59279046ca..3fa12c9de3 100644 --- a/modules/jooby-apt/src/test/java/tests/i3804/Issue3804.java +++ b/modules/jooby-apt/src/test/java/tests/i3804/Issue3804.java @@ -17,6 +17,7 @@ public void shouldDetectDIOnFieldsOfBaseClass() throws Exception { new ProcessorRunner(new C3804()) .withSourceCode( source -> { + System.out.println(source); assertTrue(source.contains("setup(ctx -> ctx.require(type));")); }); } diff --git a/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java new file mode 100644 index 0000000000..1eadb9d560 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java @@ -0,0 +1,47 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.i3830; + +import java.util.List; +import java.util.Map; + +import io.jooby.annotation.*; + +@McpServer("example-server") +public class ExampleServer { + + // 1. Tool + @McpTool(name = "calculator", description = "A simple calculator") + public int add(@McpParam(name = "a") int a, @McpParam(name = "b") int b) { + return a + b; + } + + // 2. Prompt + @McpPrompt(name = "review_code") + public String reviewCode( + @McpParam(name = "language") String language, @McpParam(name = "code") String code) { + return "Please review this " + language + " code:\n" + code; + } + + // 3. Static Resource + @McpResource("file:///logs/app.log") + public String getLogs() { + return "Log content here..."; + } + + // 4. Resource Template + @McpResource("file:///users/{id}/profile") + public Map getUserProfile(@McpParam(name = "id") String id) { + return Map.of("id", id, "name", "John Doe"); + } + + // 5. Completion (Linked to the Resource Template 'id' argument) + @McpCompletion(ref = "file:///users/{id}/profile", arg = "id") + public List completeUserId(String input) { + // In reality, this might filter a database based on the 'input' prefix + return List.of("123", "456", "789"); + } +} diff --git a/modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java new file mode 100644 index 0000000000..cc77462111 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java @@ -0,0 +1,8 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.i3830; + +public class ExampleServerMcp_ implements io.jooby.mcp.McpService {} diff --git a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java new file mode 100644 index 0000000000..e0d269a8e7 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java @@ -0,0 +1,21 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.i3830; + +import org.junit.jupiter.api.Test; + +import io.jooby.apt.ProcessorRunner; + +public class Issue3830 { + @Test + public void shouldGenerateMcpServer() throws Exception { + new ProcessorRunner(new ExampleServer()) + .withSourceCode( + source -> { + System.out.println(source); + }); + } +} diff --git a/modules/jooby-mcp/pom.xml b/modules/jooby-mcp/pom.xml new file mode 100644 index 0000000000..e169662125 --- /dev/null +++ b/modules/jooby-mcp/pom.xml @@ -0,0 +1,26 @@ + + + 4.0.0 + + io.jooby + modules + 4.1.1-SNAPSHOT + + + jooby-mcp + jooby-mcp + + + + io.jooby + jooby + ${jooby.version} + + + io.modelcontextprotocol.sdk + mcp-core + + + diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java new file mode 100644 index 0000000000..a25ebdee19 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java @@ -0,0 +1,41 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.mcp; + +import java.util.Map; + +import io.modelcontextprotocol.server.McpSyncServerExchange; + +/** High-performance dispatcher interface generated by the Jooby APT for MCP endpoints. */ +public interface McpService { + + default Object invokeTool(String name, Map args, McpSyncServerExchange exchange) + throws Exception { + throw new UnsupportedOperationException("tool: " + name + " not supported"); + } + + default Object invokePrompt(String name, Map args, McpSyncServerExchange exchange) + throws Exception { + throw new UnsupportedOperationException("prompt: " + name + " not supported"); + } + + default Object readResource(String name, Map args, McpSyncServerExchange exchange) + throws Exception { + throw new UnsupportedOperationException("resource: " + name + " not supported"); + } + + default Object readResourceByTemplate( + String templateUri, Map args, McpSyncServerExchange exchange) + throws Exception { + throw new UnsupportedOperationException("resource: " + templateUri + " not supported"); + } + + default Object invokeCompletion( + String identifier, String argumentName, String input, McpSyncServerExchange exchange) + throws Exception { + throw new UnsupportedOperationException("completion: " + identifier + " not supported"); + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i2968/C2968_.java b/modules/jooby-openapi/src/test/java/issues/i2968/C2968_.java deleted file mode 100644 index f1ae3db5b2..0000000000 --- a/modules/jooby-openapi/src/test/java/issues/i2968/C2968_.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package issues.i2968; - -import java.util.function.Function; - -import edu.umd.cs.findbugs.annotations.NonNull; -import io.jooby.Context; -import io.jooby.Extension; -import io.jooby.Jooby; -import io.jooby.annotation.Generated; - -@Generated(C2968.class) -public class C2968_ implements Extension { - private Function provider; - - public C2968_() { - this.provider = ctx -> new C2968(); - } - - @Override - public void install(@NonNull Jooby app) throws Exception { - app.get("/hello", this::hello); - } - - public String hello(Context ctx) { - return provider.apply(ctx).hello(ctx.query("name").value()); - } -} diff --git a/modules/pom.xml b/modules/pom.xml index fdfdb789dd..e1a16eb4a3 100644 --- a/modules/pom.xml +++ b/modules/pom.xml @@ -108,6 +108,7 @@ jooby-stork jooby-gradle-setup + jooby-mcp diff --git a/pom.xml b/pom.xml index 7d1026cae3..17daa20cd7 100644 --- a/pom.xml +++ b/pom.xml @@ -263,6 +263,13 @@ pom import + + io.modelcontextprotocol.sdk + mcp-bom + 1.1.0 + pom + import + io.jooby From 6db1299da6e3e16980cfc15bc3d03779b0473f49 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 21 Mar 2026 12:22:59 -0300 Subject: [PATCH 02/37] build: add constructor tests for DI vs No_DI controllers --- .../java/tests/instance/DIController.java | 23 ++++++++++ .../tests/instance/DefaultControllerTest.java | 45 +++++++++++++++++++ .../java/tests/instance/NoDIController.java | 16 +++++++ 3 files changed, 84 insertions(+) create mode 100644 modules/jooby-apt/src/test/java/tests/instance/DIController.java create mode 100644 modules/jooby-apt/src/test/java/tests/instance/DefaultControllerTest.java create mode 100644 modules/jooby-apt/src/test/java/tests/instance/NoDIController.java diff --git a/modules/jooby-apt/src/test/java/tests/instance/DIController.java b/modules/jooby-apt/src/test/java/tests/instance/DIController.java new file mode 100644 index 0000000000..0933f89015 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/instance/DIController.java @@ -0,0 +1,23 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.instance; + +import io.jooby.annotation.GET; +import jakarta.inject.Inject; + +public class DIController { + private final NoDIController controller; + + @Inject + public DIController(NoDIController controller) { + this.controller = controller; + } + + @GET("/di") + public String di() { + return controller.noDI(); + } +} diff --git a/modules/jooby-apt/src/test/java/tests/instance/DefaultControllerTest.java b/modules/jooby-apt/src/test/java/tests/instance/DefaultControllerTest.java new file mode 100644 index 0000000000..5a447322ce --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/instance/DefaultControllerTest.java @@ -0,0 +1,45 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.instance; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import io.jooby.apt.ProcessorRunner; + +public class DefaultControllerTest { + + @Test + public void shouldGenerateDIDefaultConstructor() throws Exception { + new ProcessorRunner(new DIController(new NoDIController())) + .withSourceCode( + source -> { + assertThat(source) + .containsIgnoringWhitespaces( + """ + public DIController_() { + this(DIController.class); + } + """); + }); + } + + @Test + public void shouldGenerateDefaultConstructorWithoutDI() throws Exception { + new ProcessorRunner(new NoDIController()) + .withSourceCode( + source -> { + assertThat(source) + .containsIgnoringWhitespaces( + """ + public NoDIController_() { + this(io.jooby.SneakyThrows.singleton(NoDIController::new)); + } + """); + }); + } +} diff --git a/modules/jooby-apt/src/test/java/tests/instance/NoDIController.java b/modules/jooby-apt/src/test/java/tests/instance/NoDIController.java new file mode 100644 index 0000000000..0a74243272 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/instance/NoDIController.java @@ -0,0 +1,16 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.instance; + +import io.jooby.annotation.GET; + +public class NoDIController { + + @GET("/no-di") + public String noDI() { + return "no-di"; + } +} From 61cf33135abc1abf786037ae76bb776a4615bc81 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 21 Mar 2026 18:02:35 -0300 Subject: [PATCH 03/37] - build: use the same template for all generated code - normalize: constructor generation --- .../io/jooby/internal/apt/HttpMethod.java | 1 + .../java/io/jooby/internal/apt/MvcRouter.java | 671 +++++++++--------- .../io/jooby/internal/apt/Source.java | 24 - .../resources/io/jooby/internal/apt/Source.kt | 20 - .../src/test/java/tests/i3864/Issue3868.java | 4 +- .../src/test/java/tests/instance/C2968.java | 16 + .../src/test/java/issues/i2968/App2968.java | 4 +- 7 files changed, 338 insertions(+), 402 deletions(-) delete mode 100644 modules/jooby-apt/src/main/resources/io/jooby/internal/apt/Source.java delete mode 100644 modules/jooby-apt/src/main/resources/io/jooby/internal/apt/Source.kt create mode 100644 modules/jooby-apt/src/test/java/tests/instance/C2968.java diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java index 037005135a..2cd7b3e23e 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java @@ -38,6 +38,7 @@ public enum HttpMethod implements AnnotationSupport { MCP( List.of( "io.jooby.annotation.McpTool", + "io.jooby.annotation.McpCompletion", "io.jooby.annotation.McpPrompt", "io.jooby.annotation.McpResource", "io.jooby.annotation.McpServer")); diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java index 288f847574..624d31d19a 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java @@ -11,7 +11,6 @@ import static java.util.Collections.emptyList; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.util.*; import java.util.function.BiConsumer; import java.util.function.Predicate; @@ -21,6 +20,59 @@ import javax.lang.model.element.*; public class MvcRouter { + public static final String JAVA = + """ + package ${packageName}; + ${imports} + @io.jooby.annotation.Generated(${className}.class) + public class ${generatedClassName} implements ${implements} { + protected java.util.function.Function factory; + ${constructors} + public ${generatedClassName}(${className} instance) { + setup(ctx -> instance); + } + + public ${generatedClassName}(io.jooby.SneakyThrows.Supplier<${className}> provider) { + setup(ctx -> (${className}) provider.get()); + } + + public ${generatedClassName}(io.jooby.SneakyThrows.Function, ${className}> provider) { + setup(ctx -> provider.apply(${className}.class)); + } + + private void setup(java.util.function.Function factory) { + this.factory = factory; + } + + ${methods} + } + + """; + public static final String KOTLIN = + """ + package ${packageName} + ${imports} + @io.jooby.annotation.Generated(${className}::class) + open class ${generatedClassName} : ${implements} { + private lateinit var factory: java.util.function.Function + + ${constructors} + constructor(instance: ${className}) { setup { instance } } + + constructor(provider: io.jooby.SneakyThrows.Supplier<${className}>) { setup { provider.get() } } + + constructor(provider: (Class<${className}>) -> ${className}) { setup { provider(${className}::class.java) } } + + constructor(provider: io.jooby.SneakyThrows.Function, ${className}>) { setup { provider.apply(${className}::class.java) } } + + private fun setup(factory: java.util.function.Function) { + this.factory = factory + } + ${methods} + } + + """; + private final MvcContext context; /** MVC router class. */ @@ -202,7 +254,8 @@ public String getRpcGeneratedFilename() { * @return The source code to write, or null if the controller only contains JSON-RPC routes. */ public String getRestSourceCode(Boolean generateKotlin) throws IOException { - var mvcRoutes = this.routes.values().stream().filter(it -> !it.isJsonRpc()).toList(); + var mvcRoutes = + this.routes.values().stream().filter(it -> !it.isJsonRpc() && !it.isMcpRoute()).toList(); if (mvcRoutes.isEmpty()) { return null; // Safety check if called on a JSON-RPC-only controller @@ -212,72 +265,252 @@ public String getRestSourceCode(Boolean generateKotlin) throws IOException { var generateTypeName = getTargetType().getSimpleName().toString(); var generatedClass = context.generateRouterName(generateTypeName); - try (var in = getClass().getResourceAsStream("Source" + (kt ? ".kt" : ".java"))) { - Objects.requireNonNull(in); - var suspended = mvcRoutes.stream().filter(MvcRoute::isSuspendFun).toList(); - var noSuspended = mvcRoutes.stream().filter(it -> !it.isSuspendFun()).toList(); - var buffer = new StringBuilder(); - context.generateStaticImports( - this, - (owner, fn) -> - buffer.append( - statement("import ", kt ? "" : "static ", owner, ".", fn, semicolon(kt)))); - var imports = buffer.toString(); - buffer.setLength(0); - - // begin install - if (kt) { - buffer.append(indent(4)).append("@Throws(Exception::class)").append(System.lineSeparator()); - buffer - .append(indent(4)) - .append("override fun install(app: io.jooby.Jooby) {") - .append(System.lineSeparator()); - } else { + var template = kt ? KOTLIN : JAVA; + var suspended = mvcRoutes.stream().filter(MvcRoute::isSuspendFun).toList(); + var noSuspended = mvcRoutes.stream().filter(it -> !it.isSuspendFun()).toList(); + var buffer = new StringBuilder(); + context.generateStaticImports( + this, + (owner, fn) -> + buffer.append( + statement("import ", kt ? "" : "static ", owner, ".", fn, semicolon(kt)))); + var imports = buffer.toString(); + buffer.setLength(0); + + // begin install + if (kt) { + buffer.append(indent(4)).append("@Throws(Exception::class)").append(System.lineSeparator()); + buffer + .append(indent(4)) + .append("override fun install(app: io.jooby.Jooby) {") + .append(System.lineSeparator()); + } else { + buffer + .append(indent(4)) + .append("public void install(io.jooby.Jooby app) throws Exception {") + .append(System.lineSeparator()); + } + if (!suspended.isEmpty()) { + buffer.append(statement(indent(6), "val kooby = app as io.jooby.kt.Kooby")); + buffer.append(statement(indent(6), "kooby.coroutine {")); + suspended.stream() + .flatMap(it -> it.generateMapping(kt).stream()) + .forEach(line -> buffer.append(CodeBlock.indent(8)).append(line)); + trimr(buffer); + buffer.append(System.lineSeparator()).append(statement(indent(6), "}")); + } + noSuspended.stream() + .flatMap(it -> it.generateMapping(kt).stream()) + .forEach(line -> buffer.append(CodeBlock.indent(6)).append(line)); + trimr(buffer); + buffer + .append(System.lineSeparator()) + .append(indent(4)) + .append("}") + .append(System.lineSeparator()) + .append(System.lineSeparator()); + // end install + + mvcRoutes.stream() + .flatMap(it -> it.generateHandlerCall(kt).stream()) + .forEach(line -> buffer.append(CodeBlock.indent(4)).append(line)); + + return template + .replace("${packageName}", getPackageName()) + .replace("${imports}", imports) + .replace("${className}", generateTypeName) + .replace("${generatedClassName}", generatedClass) + .replace("${implements}", "io.jooby.Extension") + .replace("${constructors}", constructors(generatedClass, kt)) + .replace("${methods}", trimr(buffer)); + } + + public String getRpcSourceCode(Boolean generateKotlin) { + var rpcRoutes = getRoutes().stream().filter(MvcRoute::isJsonRpc).toList(); + if (rpcRoutes.isEmpty()) { + return null; + } + + var kt = generateKotlin == Boolean.TRUE || isKt(); + var generateTypeName = getTargetType().getSimpleName().toString(); + var generatedClass = context.generateRouterName(generateTypeName + "Rpc"); + var namespace = getJsonRpcNamespace(); + + var template = kt ? KOTLIN : JAVA; + var buffer = new StringBuilder(); + + context.generateStaticImports( + this, + (owner, fn) -> + buffer.append( + statement("import ", kt ? "" : "static ", owner, ".", fn, semicolon(kt)))); + var imports = buffer.toString(); + buffer.setLength(0); + + List fullMethods = new ArrayList<>(); + for (MvcRoute route : rpcRoutes) { + String routeName = route.getJsonRpcMethodName(); + fullMethods.add(namespace.isEmpty() ? routeName : namespace + "." + routeName); + } + + String methodListString = + fullMethods.stream().map(m -> "\"" + m + "\"").collect(Collectors.joining(", ")); + + if (kt) { + buffer.append(indent(4)).append("@Throws(Exception::class)").append(System.lineSeparator()); + buffer + .append(indent(4)) + .append("override fun install(app: io.jooby.Jooby) {") + .append(System.lineSeparator()); + buffer + .append(indent(6)) + .append("app.services.listOf(io.jooby.rpc.jsonrpc.JsonRpcService::class.java).add(this)") + .append(System.lineSeparator()); + buffer + .append(indent(4)) + .append("}") + .append(System.lineSeparator()) + .append(System.lineSeparator()); + + buffer + .append(indent(4)) + .append("override fun getMethods(): List {") + .append(System.lineSeparator()); + buffer + .append(indent(6)) + .append("return listOf(") + .append(methodListString) + .append(")") + .append(System.lineSeparator()); + buffer + .append(indent(4)) + .append("}") + .append(System.lineSeparator()) + .append(System.lineSeparator()); + + buffer + .append(indent(4)) + .append( + "override fun execute(ctx: io.jooby.Context, req:" + + " io.jooby.rpc.jsonrpc.JsonRpcRequest): Any? {") + .append(System.lineSeparator()); + buffer.append(indent(6)).append("val c = factory.apply(ctx)").append(System.lineSeparator()); + buffer.append(indent(6)).append("val method = req.method").append(System.lineSeparator()); + buffer + .append(indent(6)) + .append("val parser = ctx.require(io.jooby.rpc.jsonrpc.JsonRpcParser::class.java)") + .append(System.lineSeparator()); + buffer.append(indent(6)).append("return when(method) {").append(System.lineSeparator()); + + for (int i = 0; i < rpcRoutes.size(); i++) { buffer - .append(indent(4)) - .append("public void install(io.jooby.Jooby app) throws Exception {") + .append(indent(8)) + .append("\"") + .append(fullMethods.get(i)) + .append("\" -> {") .append(System.lineSeparator()); + rpcRoutes.get(i).generateJsonRpcDispatchCase(true).forEach(buffer::append); + buffer.append(indent(8)).append("}").append(System.lineSeparator()); } - if (!suspended.isEmpty()) { - buffer.append(statement(indent(6), "val kooby = app as io.jooby.kt.Kooby")); - buffer.append(statement(indent(6), "kooby.coroutine {")); - suspended.stream() - .flatMap(it -> it.generateMapping(kt).stream()) - .forEach(line -> buffer.append(CodeBlock.indent(8)).append(line)); - trimr(buffer); - buffer.append(System.lineSeparator()).append(statement(indent(6), "}")); - } - noSuspended.stream() - .flatMap(it -> it.generateMapping(kt).stream()) - .forEach(line -> buffer.append(CodeBlock.indent(6)).append(line)); - trimr(buffer); + + buffer + .append(indent(8)) + .append( + "else -> throw" + + " io.jooby.rpc.jsonrpc.JsonRpcException(io.jooby.rpc.jsonrpc.JsonRpcErrorCode.METHOD_NOT_FOUND," + + " \"Method not found: $method\")") + .append(System.lineSeparator()); + buffer.append(indent(6)).append("}").append(System.lineSeparator()); + buffer.append(indent(4)).append("}").append(System.lineSeparator()); + + } else { + buffer.append(indent(4)).append("@Override").append(System.lineSeparator()); buffer + .append(indent(4)) + .append("public void install(io.jooby.Jooby app) throws Exception {") + .append(System.lineSeparator()); + buffer + .append(indent(6)) + .append("app.getServices().listOf(io.jooby.rpc.jsonrpc.JsonRpcService.class).add(this);") + .append(System.lineSeparator()); + buffer + .append(indent(4)) + .append("}") .append(System.lineSeparator()) + .append(System.lineSeparator()); + + buffer.append(indent(4)).append("@Override").append(System.lineSeparator()); + buffer + .append(indent(4)) + .append("public java.util.List getMethods() {") + .append(System.lineSeparator()); + buffer + .append(indent(6)) + .append("return java.util.List.of(") + .append(methodListString) + .append(");") + .append(System.lineSeparator()); + buffer .append(indent(4)) .append("}") .append(System.lineSeparator()) .append(System.lineSeparator()); - // end install - - mvcRoutes.stream() - .flatMap(it -> it.generateHandlerCall(kt).stream()) - .forEach(line -> buffer.append(CodeBlock.indent(4)).append(line)); - - return new String(in.readAllBytes(), StandardCharsets.UTF_8) - .replace("${packageName}", getPackageName()) - .replace("${imports}", imports) - .replace("${className}", generateTypeName) - .replace("${generatedClassName}", generatedClass) - .replace("${constructors}", constructors(generatedClass, kt)) - .replace("${methods}", trimr(buffer)); - } - } - public String getRpcSourceCode(Boolean generateKotlin) { - if (!hasJsonRpcRoutes()) { - return null; + buffer.append(indent(4)).append("@Override").append(System.lineSeparator()); + buffer + .append(indent(4)) + .append( + "public Object execute(io.jooby.Context ctx, io.jooby.rpc.jsonrpc.JsonRpcRequest req)" + + " throws Exception {") + .append(System.lineSeparator()); + buffer + .append(indent(6)) + .append(generateTypeName) + .append(" c = factory.apply(ctx);") + .append(System.lineSeparator()); + buffer + .append(indent(6)) + .append("String method = req.getMethod();") + .append(System.lineSeparator()); + buffer + .append(indent(6)) + .append( + "io.jooby.rpc.jsonrpc.JsonRpcParser parser =" + + " ctx.require(io.jooby.rpc.jsonrpc.JsonRpcParser.class);") + .append(System.lineSeparator()); + buffer.append(indent(6)).append("switch(method) {").append(System.lineSeparator()); + + for (int i = 0; i < rpcRoutes.size(); i++) { + buffer + .append(indent(8)) + .append("case \"") + .append(fullMethods.get(i)) + .append("\": {") + .append(System.lineSeparator()); + rpcRoutes.get(i).generateJsonRpcDispatchCase(false).forEach(buffer::append); + buffer.append(indent(8)).append("}").append(System.lineSeparator()); + } + + buffer.append(indent(8)).append("default:").append(System.lineSeparator()); + buffer + .append(indent(10)) + .append( + "throw new" + + " io.jooby.rpc.jsonrpc.JsonRpcException(io.jooby.rpc.jsonrpc.JsonRpcErrorCode.METHOD_NOT_FOUND," + + " \"Method not found: \" + method);") + .append(System.lineSeparator()); + buffer.append(indent(6)).append("}").append(System.lineSeparator()); + buffer.append(indent(4)).append("}").append(System.lineSeparator()); } - return generateJsonRpcService(generateKotlin == Boolean.TRUE || isKt()); + + return template + .replace("${packageName}", getPackageName()) + .replace("${imports}", imports) + .replace("${className}", generateTypeName) + .replace("${generatedClassName}", generatedClass) + .replace("${implements}", "io.jooby.rpc.jsonrpc.JsonRpcService, io.jooby.Extension") + .replace("${constructors}", constructors(generatedClass, kt)) + .replace("${methods}", trimr(buffer)); } public boolean hasMcpRoutes() { @@ -308,60 +541,42 @@ public String getMcpSourceCode(Boolean generateKotlin) { } boolean kt = generateKotlin == Boolean.TRUE || isKt(); - var buffer = new StringBuilder(); var generateTypeName = getTargetType().getSimpleName().toString(); var mcpClassName = context.generateRouterName(generateTypeName + "Mcp"); var packageName = getPackageName(); - // FIXED: Read directly from getRoutes() since we are keeping a unified map + var template = kt ? KOTLIN : JAVA; + var buffer = new StringBuilder(); + + context.generateStaticImports( + this, + (owner, fn) -> + buffer.append( + statement("import ", kt ? "" : "static ", owner, ".", fn, semicolon(kt)))); + var imports = buffer.toString(); + buffer.setLength(0); + var tools = getRoutes().stream().filter(MvcRoute::isMcpTool).toList(); var prompts = getRoutes().stream().filter(MvcRoute::isMcpPrompt).toList(); var resources = getRoutes().stream().filter(MvcRoute::isMcpResource).toList(); var templates = getRoutes().stream().filter(MvcRoute::isMcpResourceTemplate).toList(); var completions = getRoutes().stream().filter(MvcRoute::isMcpCompletion).toList(); - buffer.append(CodeBlock.statement("package ", packageName, CodeBlock.semicolon(kt))); - buffer.append(System.lineSeparator()); - - if (kt) { - // Kotlin setup... - } else { - buffer.append( - CodeBlock.statement( - "public class ", mcpClassName, " implements io.jooby.mcp.McpService {")); - - // 1. Declare the factory field - buffer.append( - CodeBlock.statement( - CodeBlock.indent(2), - "protected java.util.function.Function factory", - CodeBlock.semicolon(kt))); - - // 2. Use the EXISTING constructors() method - buffer.append(constructors(mcpClassName, false).toString().replaceAll("(?m)^ ", "")); - - // 3. Generate the setup() method required by the constructors() output - buffer - .append(CodeBlock.indent(2)) - .append("private void setup(java.util.function.Function factory) {\n"); - buffer.append(CodeBlock.indent(4)).append("this.factory = factory;\n"); - buffer.append(CodeBlock.indent(2)).append("}\n\n"); - - // --- THE DISPATCHER GENERATORS --- - buffer.append(generateMcpDispatcher("invokeTool", tools, kt, generateTypeName)); - buffer.append(generateMcpDispatcher("invokePrompt", prompts, kt, generateTypeName)); - buffer.append(generateMcpDispatcher("readResource", resources, kt, generateTypeName)); - buffer.append(generateResourceTemplateDispatcher(templates, kt, generateTypeName)); - buffer.append(generateCompletionDispatcher(completions, kt, generateTypeName)); - - buffer.append(CodeBlock.statement("}")); - } - - return buffer.toString(); + // The dispatchers map directly to the interface methods required by McpService + buffer.append(generateMcpDispatcher("invokeTool", tools, kt, generateTypeName)); + buffer.append(generateMcpDispatcher("invokePrompt", prompts, kt, generateTypeName)); + buffer.append(generateMcpDispatcher("readResource", resources, kt, generateTypeName)); + buffer.append(generateResourceTemplateDispatcher(templates, kt, generateTypeName)); + buffer.append(generateCompletionDispatcher(completions, kt, generateTypeName)); + + return template + .replace("${packageName}", packageName) + .replace("${imports}", imports) + .replace("${className}", generateTypeName) + .replace("${generatedClassName}", mcpClassName) + .replace("${implements}", "io.jooby.mcp.McpService") + .replace("${constructors}", constructors(mcpClassName, kt)) + .replace("${methods}", trimr(buffer)); } private String generateResourceTemplateDispatcher( @@ -709,260 +924,6 @@ private String extractAnnotationValue(MvcRoute route, String annotationName, Str .orElse(""); } - private String generateJsonRpcService(boolean kt) { - var buffer = new StringBuilder(); - var generateTypeName = getTargetType().getSimpleName().toString(); - var rpcClassName = context.generateRouterName(generateTypeName + "Rpc"); - var namespace = getJsonRpcNamespace(); - var packageName = getPackageName(); - - var rpcRoutes = getRoutes().stream().filter(MvcRoute::isJsonRpc).toList(); - - List fullMethods = new ArrayList<>(); - for (MvcRoute route : rpcRoutes) { - String routeName = route.getJsonRpcMethodName(); - fullMethods.add(namespace.isEmpty() ? routeName : namespace + "." + routeName); - } - - String methodListString = - fullMethods.stream().map(m -> "\"" + m + "\"").collect(Collectors.joining(", ")); - - buffer.append(statement("package ", packageName, semicolon(kt))); - buffer.append(System.lineSeparator()); - - buffer.append( - statement( - "@io.jooby.annotation.Generated(", generateTypeName, kt ? "::class" : ".class", ")")); - - if (kt) { - buffer.append( - statement( - "class ", - rpcClassName, - " : io.jooby.rpc.jsonrpc.JsonRpcService, io.jooby.Extension {")); - buffer.append( - statement( - indent(2), - "protected lateinit var factory: (io.jooby.Context) -> ", - generateTypeName)); - - String ktConstructors = constructors(rpcClassName, true).toString().replaceAll("(?m)^ ", ""); - buffer.append(ktConstructors); - buffer.append(System.lineSeparator()); - - buffer.append(statement(indent(2), "constructor(instance: ", generateTypeName, ") {")); - buffer.append(statement(indent(4), "setup { ctx -> instance }")); - buffer.append(statement(indent(2), "}")); - buffer.append(System.lineSeparator()); - - buffer.append( - statement( - indent(2), - "constructor(provider: io.jooby.SneakyThrows.Supplier<", - generateTypeName, - ">) {")); - buffer.append(statement(indent(4), "setup { ctx -> provider.get() }")); - buffer.append(statement(indent(2), "}")); - buffer.append(System.lineSeparator()); - - buffer.append( - statement( - indent(2), - "constructor(provider: io.jooby.SneakyThrows.Function, ", - generateTypeName, - ">) {")); - buffer.append( - statement( - indent(4), "setup { ctx -> provider.apply(", generateTypeName, "::class.java) }")); - buffer.append(statement(indent(2), "}")); - buffer.append(System.lineSeparator()); - - buffer.append( - statement( - indent(2), - "private fun setup(factory: (io.jooby.Context) -> ", - generateTypeName, - ") {")); - buffer.append(statement(indent(4), "this.factory = factory")); - buffer.append(statement(indent(2), "}")); - buffer.append(System.lineSeparator()); - - buffer.append(statement(indent(2), "override fun install(app: io.jooby.Jooby) {")); - buffer.append( - statement( - indent(4), - "app.services.listOf(io.jooby.rpc.jsonrpc.JsonRpcService::class.java).add(this)")); - buffer.append(statement(indent(2), "}")); - buffer.append(System.lineSeparator()); - - buffer.append(statement(indent(2), "override fun getMethods(): List {")); - buffer.append(statement(indent(4), "return listOf(", methodListString, ")")); - buffer.append(statement(indent(2), "}")); - buffer.append(System.lineSeparator()); - - buffer.append(statement(indent(2), "@Throws(Exception::class)")); - buffer.append( - statement( - indent(2), - "override fun execute(ctx: io.jooby.Context, req:" - + " io.jooby.rpc.jsonrpc.JsonRpcRequest): Any? {")); - buffer.append(statement(indent(4), "val c = factory(ctx)")); - buffer.append(statement(indent(4), "val method = req.method")); - buffer.append( - statement( - indent(4), - var(kt), - "parser = ctx.require(io.jooby.rpc.jsonrpc.JsonRpcParser", - clazz(kt), - ")", - semicolon(kt))); - buffer.append(statement(indent(4), "return when(method) {")); - - for (int i = 0; i < rpcRoutes.size(); i++) { - buffer.append(statement(indent(6), "\"", fullMethods.get(i), "\" -> {")); - rpcRoutes.get(i).generateJsonRpcDispatchCase(true).forEach(buffer::append); - buffer.append(statement(indent(6), "}")); - } - - buffer.append( - statement( - indent(6), - "else -> throw" - + " io.jooby.rpc.jsonrpc.JsonRpcException(io.jooby.rpc.jsonrpc.JsonRpcErrorCode.METHOD_NOT_FOUND," - + " \"Method not found: $method\")")); - buffer.append(statement(indent(4), "}")); - buffer.append(statement(indent(2), "}")); - - } else { - buffer.append( - statement( - "public class ", - rpcClassName, - " implements io.jooby.rpc.jsonrpc.JsonRpcService, io.jooby.Extension {")); - buffer.append( - statement( - indent(2), - "protected java.util.function.Function factory", - semicolon(kt))); - - String javaConstructors = - constructors(rpcClassName, false).toString().replaceAll("(?m)^ ", ""); - buffer.append(javaConstructors); - buffer.append(System.lineSeparator()); - - buffer.append( - statement(indent(2), "public ", rpcClassName, "(", generateTypeName, " instance) {")); - buffer.append(statement(indent(4), "setup(ctx -> instance)", semicolon(kt))); - buffer.append(statement(indent(2), "}")); - buffer.append(System.lineSeparator()); - - buffer.append( - statement( - indent(2), - "public ", - rpcClassName, - "(io.jooby.SneakyThrows.Supplier<", - generateTypeName, - "> provider) {")); - buffer.append( - statement( - indent(4), "setup(ctx -> (", generateTypeName, ") provider.get())", semicolon(kt))); - buffer.append(statement(indent(2), "}")); - buffer.append(System.lineSeparator()); - - buffer.append( - statement( - indent(2), - "public ", - rpcClassName, - "(io.jooby.SneakyThrows.Function, ", - generateTypeName, - "> provider) {")); - buffer.append( - statement( - indent(4), - "setup(ctx -> provider.apply(", - generateTypeName, - ".class))", - semicolon(kt))); - buffer.append(statement(indent(2), "}")); - buffer.append(System.lineSeparator()); - - buffer.append( - statement( - indent(2), - "private void setup(java.util.function.Function factory) {")); - buffer.append(statement(indent(4), "this.factory = factory", semicolon(kt))); - buffer.append(statement(indent(2), "}")); - buffer.append(System.lineSeparator()); - - buffer.append(statement(indent(2), "@Override")); - buffer.append( - statement(indent(2), "public void install(io.jooby.Jooby app) throws Exception {")); - buffer.append( - statement( - indent(4), - "app.getServices().listOf(io.jooby.rpc.jsonrpc.JsonRpcService.class).add(this)", - semicolon(kt))); - buffer.append(statement(indent(2), "}")); - buffer.append(System.lineSeparator()); - - buffer.append(statement(indent(2), "@Override")); - buffer.append(statement(indent(2), "public java.util.List getMethods() {")); - buffer.append( - statement(indent(4), "return java.util.List.of(", methodListString, ")", semicolon(kt))); - buffer.append(statement(indent(2), "}")); - buffer.append(System.lineSeparator()); - - buffer.append(statement(indent(2), "@Override")); - buffer.append( - statement( - indent(2), - "public Object execute(io.jooby.Context ctx, io.jooby.rpc.jsonrpc.JsonRpcRequest req)" - + " throws Exception {")); - buffer.append( - statement(indent(4), generateTypeName, " c = factory.apply(ctx)", semicolon(kt))); - buffer.append(statement(indent(4), "String method = req.getMethod()", semicolon(kt))); - buffer.append( - statement( - indent(4), - var(kt), - "parser = ctx.require(io.jooby.rpc.jsonrpc.JsonRpcParser", - clazz(kt), - ")", - semicolon(kt))); - buffer.append(statement(indent(4), "switch(method) {")); - - for (int i = 0; i < rpcRoutes.size(); i++) { - buffer.append(statement(indent(6), "case \"", fullMethods.get(i), "\": {")); - rpcRoutes.get(i).generateJsonRpcDispatchCase(false).forEach(buffer::append); - buffer.append(statement(indent(6), "}")); - } - - buffer.append( - statement( - indent(6), - "default: throw new" - + " io.jooby.rpc.jsonrpc.JsonRpcException(io.jooby.rpc.jsonrpc.JsonRpcErrorCode.METHOD_NOT_FOUND," - + " \"Method not found: \" + method)", - semicolon(kt))); - buffer.append(statement(indent(4), "}")); - buffer.append(statement(indent(2), "}")); - } - - buffer.append(statement("}")); - - return buffer.toString(); - } - private StringBuilder trimr(StringBuilder buffer) { var i = buffer.length() - 1; while (i > 0 && Character.isWhitespace(buffer.charAt(i))) { diff --git a/modules/jooby-apt/src/main/resources/io/jooby/internal/apt/Source.java b/modules/jooby-apt/src/main/resources/io/jooby/internal/apt/Source.java deleted file mode 100644 index 1ef778624e..0000000000 --- a/modules/jooby-apt/src/main/resources/io/jooby/internal/apt/Source.java +++ /dev/null @@ -1,24 +0,0 @@ -package ${packageName}; -${imports} -@io.jooby.annotation.Generated(${className}.class) -public class ${generatedClassName} implements io.jooby.Extension { - protected java.util.function.Function factory; -${constructors} - public ${generatedClassName}(${className} instance) { - setup(ctx -> instance); - } - - public ${generatedClassName}(io.jooby.SneakyThrows.Supplier<${className}> provider) { - setup(ctx -> (${className}) provider.get()); - } - - public ${generatedClassName}(io.jooby.SneakyThrows.Function, ${className}> provider) { - setup(ctx -> provider.apply(${className}.class)); - } - - private void setup(java.util.function.Function factory) { - this.factory = factory; - } - -${methods} -} diff --git a/modules/jooby-apt/src/main/resources/io/jooby/internal/apt/Source.kt b/modules/jooby-apt/src/main/resources/io/jooby/internal/apt/Source.kt deleted file mode 100644 index cb1dfca701..0000000000 --- a/modules/jooby-apt/src/main/resources/io/jooby/internal/apt/Source.kt +++ /dev/null @@ -1,20 +0,0 @@ -package ${packageName} -${imports} -@io.jooby.annotation.Generated(${className}::class) -open class ${generatedClassName} : io.jooby.Extension { - private lateinit var factory: java.util.function.Function - - ${constructors} - constructor(instance: ${className}) { setup { instance } } - - constructor(provider: io.jooby.SneakyThrows.Supplier<${className}>) { setup { provider.get() } } - - constructor(provider: (Class<${className}>) -> ${className}) { setup { provider(${className}::class.java) } } - - constructor(provider: io.jooby.SneakyThrows.Function, ${className}>) { setup { provider.apply(${className}::class.java) } } - - private fun setup(factory: java.util.function.Function) { - this.factory = factory - } -${methods} -} diff --git a/modules/jooby-apt/src/test/java/tests/i3864/Issue3868.java b/modules/jooby-apt/src/test/java/tests/i3864/Issue3868.java index 0a545e471d..7c259c42c8 100644 --- a/modules/jooby-apt/src/test/java/tests/i3864/Issue3868.java +++ b/modules/jooby-apt/src/test/java/tests/i3864/Issue3868.java @@ -94,9 +94,9 @@ public void shouldGenerateDefaultConstructorForDI() throws Exception { .withSourceCode( source -> { assertThat(source) - .containsIgnoringNewLines( + .containsIgnoringWhitespaces( "public DIServiceRpc_() {\n" + " this(DIService.class);\n" + " }") - .containsIgnoringNewLines( + .containsIgnoringWhitespaces( "public DIServiceRpc_(Class type) {\n" + " setup(ctx -> ctx.require(type));\n" + " }"); diff --git a/modules/jooby-apt/src/test/java/tests/instance/C2968.java b/modules/jooby-apt/src/test/java/tests/instance/C2968.java new file mode 100644 index 0000000000..8d41c96dbf --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/instance/C2968.java @@ -0,0 +1,16 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.instance; + +import io.jooby.annotation.GET; +import io.jooby.annotation.QueryParam; + +public class C2968 { + @GET("/hello") + public String hello(@QueryParam String name) { + return name; + } +} diff --git a/modules/jooby-openapi/src/test/java/issues/i2968/App2968.java b/modules/jooby-openapi/src/test/java/issues/i2968/App2968.java index 902bd2413a..9f1a502f5c 100644 --- a/modules/jooby-openapi/src/test/java/issues/i2968/App2968.java +++ b/modules/jooby-openapi/src/test/java/issues/i2968/App2968.java @@ -5,11 +5,13 @@ */ package issues.i2968; +import static io.jooby.openapi.MvcExtensionGenerator.toMvcExtension; + import io.jooby.Jooby; public class App2968 extends Jooby { { - mvc(new C2968_()); + mvc(toMvcExtension(C2968.class)); } } From d5df0a18ce5d9e25b74697bcb7dfacf0a0b17ec7 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Wed, 25 Mar 2026 06:55:10 -0300 Subject: [PATCH 04/37] feat(mcp): complete APT code generation for MCP server features Implement full Annotation Processor (APT) support for generating MCP server specifications and handlers, bridging Jooby controllers with the Model Context Protocol SDK. Key implementations: * Annotations: Add code generation for @McpTool, @McpPrompt, @McpResource, and @McpCompletion. * Completion Routing: Implement "Group and Route" architecture. Groups multiple argument-specific completion methods under a single MCP Reference and routes them at runtime using compile-time generated enhanced switch/when expressions. * Dynamic Resources: Auto-detect URI placeholders (`{}`) to dynamically register as Resource Templates or static Resources. * Template Variables: Integrate `DefaultMcpUriTemplateManager` to safely extract and bind path variables from `req.uri()` into controller args. * Schema Generation: Optimize Jackson 3 (`tools.jackson`) schema builder to automatically omit output schemas for primitives, standard java.lang types, and internal MCP classes. * Idiomatic Output: Generate clean, modern Java (var, enhanced switch) and Kotlin (val, when) with strict null-safety checks and safe default fallbacks for unmatched requests. --- modules/jooby-apt/pom.xml | 7 + .../java/io/jooby/internal/apt/MvcRoute.java | 625 +++++++++++++- .../java/io/jooby/internal/apt/MvcRouter.java | 769 ++++++++++-------- .../test/java/tests/i3830/ExampleServer.java | 27 +- .../java/tests/i3830/ExampleServerMcp_.java | 8 - .../src/test/java/tests/i3830/Issue3830.java | 177 +++- 6 files changed, 1265 insertions(+), 348 deletions(-) delete mode 100644 modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java diff --git a/modules/jooby-apt/pom.xml b/modules/jooby-apt/pom.xml index 6fb1fb6580..47ede91594 100644 --- a/modules/jooby-apt/pom.xml +++ b/modules/jooby-apt/pom.xml @@ -32,6 +32,13 @@ test + + com.github.victools + jsonschema-generator + 5.0.0 + test + + io.jooby jooby-jackson3 diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java index 4f2d28a90d..21d0da5383 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java @@ -111,10 +111,7 @@ public MvcRoute checkMcpAnnotations() { != null) { this.isMcpPrompt = true; } - if (AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.McpResource") - != null) { - this.isMcpResource = true; - } + var resourceAnno = AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.McpResource"); if (resourceAnno != null) { @@ -328,6 +325,612 @@ static String leadingSlash(String path) { return path.charAt(0) == '/' ? path : "/" + path; } + public List generateMcpDefinitionMethod(boolean kt) { + List buffer = new ArrayList<>(); + + if (isMcpTool()) { + String toolName = extractAnnotationValue(this, "io.jooby.annotation.McpTool", "name"); + if (toolName.isEmpty()) toolName = getMethodName(); + String description = + extractAnnotationValue(this, "io.jooby.annotation.McpTool", "description"); + + if (kt) { + buffer.add( + statement( + indent(4), + "private fun ", + getMethodName(), + "ToolSpec(mapper: tools.jackson.databind.ObjectMapper, schemaGenerator:" + + " com.github.victools.jsonschema.generator.SchemaGenerator):" + + " io.modelcontextprotocol.spec.McpSchema.Tool {")); + buffer.add(statement(indent(6), "val schema = mapper.createObjectNode()")); + buffer.add( + statement(indent(6), "schema.put(", string("type"), ", ", string("object"), ")")); + buffer.add( + statement(indent(6), "val props = schema.putObject(", string("properties"), ")")); + buffer.add(statement(indent(6), "val req = schema.putArray(", string("required"), ")")); + } else { + buffer.add( + statement( + indent(4), + "private io.modelcontextprotocol.spec.McpSchema.Tool ", + getMethodName(), + "ToolSpec(tools.jackson.databind.ObjectMapper mapper," + + " com.github.victools.jsonschema.generator.SchemaGenerator" + + " schemaGenerator) {")); + buffer.add(statement(indent(6), "var schema = mapper.createObjectNode()", semicolon(kt))); + buffer.add( + statement( + indent(6), + "schema.put(", + string("type"), + ", ", + string("object"), + ")", + semicolon(kt))); + buffer.add( + statement( + indent(6), + "var props = schema.putObject(", + string("properties"), + ")", + semicolon(kt))); + buffer.add( + statement( + indent(6), "var req = schema.putArray(", string("required"), ")", semicolon(kt))); + } + + for (MvcParameter param : getParameters(false)) { + String type = param.getType().getRawType().toString(); + if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange") + || type.equals("io.jooby.Context")) continue; + + String mcpName = param.getMcpName(); + + if (kt) { + buffer.add( + statement( + indent(6), + "props.set(", + string(mcpName), + ", schemaGenerator.generateSchema(", + type, + "::class.java))")); + if (!param.isNullable(kt)) + buffer.add(statement(indent(6), "req.add(", string(mcpName), ")")); + } else { + buffer.add( + statement( + indent(6), + "props.set(", + string(mcpName), + ", schemaGenerator.generateSchema(", + type, + ".class))", + semicolon(kt))); + if (!param.isNullable(kt)) + buffer.add(statement(indent(6), "req.add(", string(mcpName), ")", semicolon(kt))); + } + } + + // Filter out primitives, java.lang classes (String, Integer, etc.), and MCP classes + String returnTypeStr = getReturnType().getRawType().toString(); + boolean isPrimitive = + returnTypeStr.equals("int") + || returnTypeStr.equals("long") + || returnTypeStr.equals("double") + || returnTypeStr.equals("float") + || returnTypeStr.equals("boolean") + || returnTypeStr.equals("byte") + || returnTypeStr.equals("short") + || returnTypeStr.equals("char"); + boolean isLangClass = returnTypeStr.startsWith("java.lang."); + boolean isMcpClass = returnTypeStr.startsWith("io.modelcontextprotocol.spec.McpSchema"); + + boolean generateOutputSchema = + !returnType.isVoid() + && !getReturnType().is("io.jooby.StatusCode") + && !isPrimitive + && !isLangClass + && !isMcpClass; + + String outputSchemaArg = "null"; + + if (generateOutputSchema) { + outputSchemaArg = getMethodName() + "OutputSchema"; + if (kt) { + buffer.add( + statement( + indent(6), + "val ", + outputSchemaArg, + "Node = schemaGenerator.generateSchema(", + returnTypeStr, + "::class.java)")); + // Clean and simple Map class conversion! + buffer.add( + statement( + indent(6), + "val ", + outputSchemaArg, + " = mapper.convertValue(", + outputSchemaArg, + "Node, Map::class.java) as Map")); + } else { + buffer.add( + statement( + indent(6), + "var ", + outputSchemaArg, + "Node = schemaGenerator.generateSchema(", + returnTypeStr, + ".class)", + semicolon(kt))); + // Clean and simple Map class conversion! + buffer.add( + statement( + indent(6), + "var ", + outputSchemaArg, + " = mapper.convertValue(", + outputSchemaArg, + "Node, java.util.Map.class)", + semicolon(kt))); + } + } + + if (kt) { + buffer.add( + statement( + indent(6), + "return io.modelcontextprotocol.spec.McpSchema.Tool(", + string(toolName), + ", null, ", + string(description), + ", mapper.treeToValue(schema," + + " io.modelcontextprotocol.spec.McpSchema.JsonSchema::class.java), ", + outputSchemaArg, + ", null, null)")); + } else { + buffer.add( + statement( + indent(6), + "return new io.modelcontextprotocol.spec.McpSchema.Tool(", + string(toolName), + ", null, ", + string(description), + ", mapper.treeToValue(schema," + + " io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), ", + outputSchemaArg, + ", null, null)", + semicolon(kt))); + } + buffer.add(statement(indent(4), "}\n")); + + } else if (isMcpPrompt()) { + String promptName = extractAnnotationValue(this, "io.jooby.annotation.McpPrompt", "name"); + if (promptName.isEmpty()) promptName = getMethodName(); + String description = + extractAnnotationValue(this, "io.jooby.annotation.McpPrompt", "description"); + + if (kt) { + buffer.add( + statement( + indent(4), + "private fun ", + getMethodName(), + "PromptSpec(): io.modelcontextprotocol.spec.McpSchema.Prompt {")); + buffer.add( + statement( + indent(6), + "val args =" + + " mutableListOf()")); + } else { + buffer.add( + statement( + indent(4), + "private io.modelcontextprotocol.spec.McpSchema.Prompt ", + getMethodName(), + "PromptSpec() {")); + buffer.add( + statement( + indent(6), + "var args = new" + + " java.util.ArrayList()", + semicolon(kt))); + } + + for (MvcParameter param : getParameters(false)) { + String type = param.getType().getRawType().toString(); + if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange") + || type.equals("io.jooby.Context")) continue; + + String mcpName = param.getMcpName(); + boolean isRequired = !param.isNullable(kt); + + if (kt) { + buffer.add( + statement( + indent(6), + "args.add(io.modelcontextprotocol.spec.McpSchema.PromptArgument(", + string(mcpName), + ", null, ", + String.valueOf(isRequired), + "))")); + } else { + buffer.add( + statement( + indent(6), + "args.add(new io.modelcontextprotocol.spec.McpSchema.PromptArgument(", + string(mcpName), + ", null, ", + String.valueOf(isRequired), + "))", + semicolon(kt))); + } + } + + if (kt) { + buffer.add( + statement( + indent(6), + "return io.modelcontextprotocol.spec.McpSchema.Prompt(", + string(promptName), + ", null, ", + string(description), + ", args)")); + } else { + buffer.add( + statement( + indent(6), + "return new io.modelcontextprotocol.spec.McpSchema.Prompt(", + string(promptName), + ", null, ", + string(description), + ", args)", + semicolon(kt))); + } + buffer.add(statement(indent(4), "}\n")); + + } else if (isMcpResource() || isMcpResourceTemplate()) { + String uri = extractAnnotationValue(this, "io.jooby.annotation.McpResource", "value"); + String name = extractAnnotationValue(this, "io.jooby.annotation.McpResource", "name"); + if (name.isEmpty()) name = getMethodName(); + String description = + extractAnnotationValue(this, "io.jooby.annotation.McpResource", "description"); + + boolean isTemplate = uri != null && uri.contains("{") && uri.contains("}"); + String specType = isTemplate ? "ResourceTemplate" : "Resource"; + + if (kt) { + buffer.add( + statement( + indent(4), + "private fun ", + getMethodName(), + specType, + "Spec(): io.modelcontextprotocol.spec.McpSchema.", + specType, + " {")); + if (!isTemplate) { + buffer.add( + statement( + indent(6), + "return io.modelcontextprotocol.spec.McpSchema.Resource(", + string(uri), + ", ", + string(name), + ", null, ", + string(description), + ", null, null, null, null)")); + } else { + buffer.add( + statement( + indent(6), + "return io.modelcontextprotocol.spec.McpSchema.ResourceTemplate(", + string(uri), + ", ", + string(name), + ", null, ", + string(description), + ", null, null, null)")); + } + buffer.add(statement(indent(4), "}\n")); + } else { + buffer.add( + statement( + indent(4), + "private io.modelcontextprotocol.spec.McpSchema.", + specType, + " ", + getMethodName(), + specType, + "Spec() {")); + if (!isTemplate) { + buffer.add( + statement( + indent(6), + "return new io.modelcontextprotocol.spec.McpSchema.Resource(", + string(uri), + ", ", + string(name), + ", null, ", + string(description), + ", null, null, null, null)", + semicolon(kt))); + } else { + buffer.add( + statement( + indent(6), + "return new io.modelcontextprotocol.spec.McpSchema.ResourceTemplate(", + string(uri), + ", ", + string(name), + ", null, ", + string(description), + ", null, null, null)", + semicolon(kt))); + } + buffer.add(statement(indent(4), "}\n")); + } + } + return buffer; + } + + public List generateMcpHandlerMethod(boolean kt) { + List buffer = new ArrayList<>(); + + String reqType = ""; + String resType = ""; + String toMethod = ""; + + if (isMcpTool()) { + reqType = "CallToolRequest"; + resType = "CallToolResult"; + toMethod = "toCallToolResult"; + } else if (isMcpPrompt()) { + reqType = "GetPromptRequest"; + resType = "GetPromptResult"; + toMethod = "toPromptResult"; + } else if (isMcpResource() || isMcpResourceTemplate()) { + reqType = "ReadResourceRequest"; + resType = "ReadResourceResult"; + toMethod = "toResourceResult"; + } else { + return buffer; + } + + if (kt) { + buffer.add( + statement( + indent(4), + "private fun ", + getMethodName(), + "(exchange: io.modelcontextprotocol.server.McpSyncServerExchange, req:" + + " io.modelcontextprotocol.spec.McpSchema.", + reqType, + "): io.modelcontextprotocol.spec.McpSchema.", + resType, + " {")); + buffer.add( + statement( + indent(6), + "val ctx =" + + " exchange.transportContext().get(io.jooby.Context::class.java.name)")); + } else { + buffer.add( + statement( + indent(4), + "private io.modelcontextprotocol.spec.McpSchema.", + resType, + " ", + getMethodName(), + "(io.modelcontextprotocol.server.McpSyncServerExchange exchange," + + " io.modelcontextprotocol.spec.McpSchema.", + reqType, + " req) {")); + buffer.add( + statement( + indent(6), + "var ctx = (io.jooby.Context) exchange.transportContext().get(\"CTX\")", + semicolon(kt))); + } + + if (isMcpTool() || isMcpPrompt()) { + if (kt) { + buffer.add(statement(indent(6), "val args = req.arguments() ?: emptyMap()")); + } else { + buffer.add( + statement( + indent(6), + "var args = req.arguments() != null ? req.arguments() :" + + " java.util.Collections.emptyMap()", + semicolon(kt))); + } + } else if (isMcpResource() || isMcpResourceTemplate()) { + String uriTemplate = extractAnnotationValue(this, "io.jooby.annotation.McpResource", "value"); + boolean isTemplate = isMcpResourceTemplate(); + + if (isTemplate) { + if (kt) { + buffer.add(statement(indent(6), "val uri = req.uri()")); + buffer.add( + statement( + indent(6), + "val manager = io.modelcontextprotocol.util.DefaultMcpUriTemplateManager(", + string(uriTemplate), + ")")); + buffer.add(statement(indent(6), "val args = mutableMapOf()")); + buffer.add(statement(indent(6), "args.putAll(manager.extractVariableValues(uri))")); + } else { + buffer.add(statement(indent(6), "var uri = req.uri()", semicolon(kt))); + buffer.add( + statement( + indent(6), + "var manager = new io.modelcontextprotocol.util.DefaultMcpUriTemplateManager(", + string(uriTemplate), + ")", + semicolon(kt))); + buffer.add( + statement( + indent(6), "var args = new java.util.HashMap()", semicolon(kt))); + buffer.add( + statement( + indent(6), "args.putAll(manager.extractVariableValues(uri))", semicolon(kt))); + } + } else { + if (kt) { + buffer.add(statement(indent(6), "val args = emptyMap()")); + } else { + buffer.add( + statement( + indent(6), + "var args = java.util.Collections.emptyMap()", + semicolon(kt))); + } + } + } + + buffer.add( + statement(indent(6), kt ? "val " : "var ", "c = this.factory.apply(ctx)", semicolon(kt))); + + List javaParamNames = new ArrayList<>(); + for (MvcParameter param : getParameters(false)) { + String javaName = param.getName(); + String mcpName = param.getMcpName(); + String type = param.getType().getRawType().toString(); + boolean isNullable = param.isNullable(kt); + javaParamNames.add(javaName); + + if (type.equals("io.jooby.Context")) { + buffer.add(statement(indent(6), kt ? "val " : "var ", javaName, " = ctx", semicolon(kt))); + continue; + } else if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange")) { + buffer.add( + statement(indent(6), kt ? "val " : "var ", javaName, " = exchange", semicolon(kt))); + continue; + } else if (type.equals("io.modelcontextprotocol.spec.McpSchema." + reqType)) { + buffer.add(statement(indent(6), kt ? "val " : "var ", javaName, " = req", semicolon(kt))); + continue; + } + + if (kt) { + buffer.add( + statement(indent(6), "val raw_", javaName, " = args.get(", string(mcpName), ")")); + if (!isNullable) + buffer.add( + statement( + indent(6), + "if (raw_", + javaName, + " == null) throw IllegalArgumentException(", + string("Missing req param: " + mcpName), + ")")); + buffer.add( + statement( + indent(6), + "val ", + javaName, + " = raw_", + javaName, + " as ", + type, + isNullable ? "?" : "")); + } else { + buffer.add( + statement( + indent(6), + "var raw_", + javaName, + " = args.get(", + string(mcpName), + ")", + semicolon(kt))); + if (!isNullable) + buffer.add( + statement( + indent(6), + "if (raw_", + javaName, + " == null) throw new IllegalArgumentException(", + string("Missing req param: " + mcpName), + ")", + semicolon(kt))); + + if (type.equals("int") || type.equals("java.lang.Integer")) { + buffer.add( + statement( + indent(6), + "var ", + javaName, + " = ", + isNullable ? "(raw_" + javaName + " == null) ? null : " : "", + "raw_", + javaName, + " instanceof Number ? ((Number) raw_", + javaName, + ").intValue() : Integer.parseInt(raw_", + javaName, + ".toString())", + semicolon(kt))); + } else if (type.equals("java.lang.String")) { + buffer.add( + statement( + indent(6), + "var ", + javaName, + " = raw_", + javaName, + " != null ? raw_", + javaName, + ".toString() : null", + semicolon(kt))); + } else { + buffer.add( + statement( + indent(6), "var ", javaName, " = (", type, ") raw_", javaName, semicolon(kt))); + } + } + } + + String methodCall = "c." + getMethodName() + "(" + String.join(", ", javaParamNames) + ")"; + + if (getReturnType().isVoid()) { + buffer.add(statement(indent(6), methodCall, semicolon(kt))); + if (kt) { + buffer.add( + statement(indent(6), "return io.jooby.mcp.McpResult(this.json).", toMethod, "(null)")); + } else { + buffer.add( + statement( + indent(6), + "return new io.jooby.mcp.McpResult(this.json).", + toMethod, + "(null)", + semicolon(kt))); + } + } else { + if (kt) { + buffer.add(statement(indent(6), "val result = ", methodCall)); + buffer.add( + statement( + indent(6), "return io.jooby.mcp.McpResult(this.json).", toMethod, "(result)")); + } else { + buffer.add(statement(indent(6), "var result = ", methodCall, semicolon(kt))); + buffer.add( + statement( + indent(6), + "return new io.jooby.mcp.McpResult(this.json).", + toMethod, + "(result)", + semicolon(kt))); + } + } + buffer.add(statement(indent(4), "}\n")); + + return buffer; + } + public List generateJsonRpcDispatchCase(boolean kt) { var buffer = new ArrayList(); var paramList = new StringJoiner(", ", "(", ")"); @@ -1317,4 +1920,18 @@ public String trpcPath(Element element) { .map(segment -> segment.startsWith("/") ? segment.substring(1) : segment) .collect(Collectors.joining("/", "/", "")); } + + private String extractAnnotationValue(MvcRoute route, String annotationName, String attribute) { + var annotation = + io.jooby.internal.apt.AnnotationSupport.findAnnotationByName( + route.getMethod(), annotationName); + if (annotation == null) { + return ""; + } + return io.jooby.internal.apt.AnnotationSupport.findAnnotationValue( + annotation, attribute::equals) + .stream() + .findFirst() + .orElse(""); + } } diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java index 624d31d19a..1d4dca556d 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java @@ -558,356 +558,474 @@ public String getMcpSourceCode(Boolean generateKotlin) { var tools = getRoutes().stream().filter(MvcRoute::isMcpTool).toList(); var prompts = getRoutes().stream().filter(MvcRoute::isMcpPrompt).toList(); - var resources = getRoutes().stream().filter(MvcRoute::isMcpResource).toList(); - var templates = getRoutes().stream().filter(MvcRoute::isMcpResourceTemplate).toList(); - var completions = getRoutes().stream().filter(MvcRoute::isMcpCompletion).toList(); - - // The dispatchers map directly to the interface methods required by McpService - buffer.append(generateMcpDispatcher("invokeTool", tools, kt, generateTypeName)); - buffer.append(generateMcpDispatcher("invokePrompt", prompts, kt, generateTypeName)); - buffer.append(generateMcpDispatcher("readResource", resources, kt, generateTypeName)); - buffer.append(generateResourceTemplateDispatcher(templates, kt, generateTypeName)); - buffer.append(generateCompletionDispatcher(completions, kt, generateTypeName)); - - return template - .replace("${packageName}", packageName) - .replace("${imports}", imports) - .replace("${className}", generateTypeName) - .replace("${generatedClassName}", mcpClassName) - .replace("${implements}", "io.jooby.mcp.McpService") - .replace("${constructors}", constructors(mcpClassName, kt)) - .replace("${methods}", trimr(buffer)); - } - - private String generateResourceTemplateDispatcher( - List templates, boolean kt, String generateTypeName) { - StringBuilder buffer = new StringBuilder(); - buffer.append(CodeBlock.indent(2)).append("@Override\n"); - buffer - .append(CodeBlock.indent(2)) - .append( - "public Object readResourceByTemplate(String templateUri, java.util.Map" - + " args, io.modelcontextprotocol.server.McpSyncServerExchange exchange) throws" - + " Exception {\n"); - - if (templates.isEmpty()) { - buffer - .append(CodeBlock.indent(4)) - .append( - "throw new UnsupportedOperationException(\"readResourceByTemplate is not supported by" - + " this server\");\n"); - buffer.append(CodeBlock.indent(2)).append("}\n\n"); - return buffer.toString(); + // FIXED: Now properly includes Templates so capabilities.resources() activates + var resources = + getRoutes().stream().filter(r -> r.isMcpResource() || r.isMcpResourceTemplate()).toList(); + + // 1. Group Completions by Reference + var completionRoutes = getRoutes().stream().filter(MvcRoute::isMcpCompletion).toList(); + java.util.Map> completionGroups = + new java.util.LinkedHashMap<>(); + for (MvcRoute route : completionRoutes) { + String ref = extractAnnotationValue(route, "io.jooby.annotation.McpCompletion", "value"); + if (ref == null || ref.isEmpty()) { + ref = extractAnnotationValue(route, "io.jooby.annotation.McpCompletion", "ref"); + } + completionGroups.computeIfAbsent(ref, k -> new java.util.ArrayList<>()).add(route); } - buffer - .append(CodeBlock.indent(4)) - .append( - "io.jooby.Context ctx = (io.jooby.Context)" - + " exchange.transportContext().get(\"CTX\");\n"); - buffer - .append(CodeBlock.indent(4)) - .append(generateTypeName) - .append(" c = factory.apply(ctx);\n"); - buffer - .append(CodeBlock.indent(4)) - .append( - "tools.jackson.databind.ObjectMapper mapper =" - + " ctx.require(tools.jackson.databind.ObjectMapper.class);\n\n"); + // Generate JSON Mapper Field + if (kt) { + buffer.append( + statement( + indent(4), + "private lateinit var json: io.modelcontextprotocol.json.McpJsonMapper\n")); + } else { + buffer.append( + statement( + indent(4), + "private io.modelcontextprotocol.json.McpJsonMapper json", + semicolon(kt), + "\n")); + } - buffer.append(CodeBlock.indent(4)).append("switch (templateUri) {\n"); + // Generate capabilities() + if (kt) { + buffer.append( + statement( + indent(4), + "override fun capabilities(capabilities:" + + " io.modelcontextprotocol.spec.McpSchema.ServerCapabilities.Builder) {")); + } else { + buffer.append(statement(indent(4), "@Override")); + buffer.append( + statement( + indent(4), + "public void" + + " capabilities(io.modelcontextprotocol.spec.McpSchema.ServerCapabilities.Builder" + + " capabilities) {")); + } + if (!tools.isEmpty()) + buffer.append(statement(indent(6), "capabilities.tools(true)", semicolon(kt))); + if (!prompts.isEmpty()) + buffer.append(statement(indent(6), "capabilities.prompts(true)", semicolon(kt))); + if (!resources.isEmpty()) + buffer.append(statement(indent(6), "capabilities.resources(true, true)", semicolon(kt))); + buffer.append(statement(indent(4), "}\n")); + + // Generate serverName() + String serverName = getMcpServerKey(); + if (kt) { + buffer.append(statement(indent(4), "override fun serverName(): String? {")); + buffer.append(statement(indent(6), "return ", string(serverName))); + } else { + buffer.append(statement(indent(4), "@Override")); + buffer.append(statement(indent(4), "public String serverName() {")); + buffer.append(statement(indent(6), "return ", string(serverName), semicolon(kt))); + } + buffer.append(statement(indent(4), "}\n")); - for (MvcRoute route : templates) { - String uriTemplate = - extractAnnotationValue(route, "io.jooby.annotation.McpResource", "value"); - buffer.append(CodeBlock.indent(6)).append("case \"").append(uriTemplate).append("\": {\n"); + // Generate completions() list + if (kt) { + buffer.append( + statement( + indent(4), + "override fun completions():" + + " List" + + " {")); + buffer.append( + statement( + indent(6), + "val completions =" + + " mutableListOf()")); + } else { + buffer.append(statement(indent(4), "@Override")); + buffer.append( + statement( + indent(4), + "public" + + " java.util.List" + + " completions() {")); + buffer.append( + statement( + indent(6), + "var completions = new" + + " java.util.ArrayList()", + semicolon(kt))); + } - List javaParamNames = new ArrayList<>(); - for (MvcParameter param : route.getParameters(false)) { - String javaName = param.getName(); - String mcpName = param.getMcpName(); - String type = param.getType().getRawType().toString(); - javaParamNames.add(javaName); + for (String ref : completionGroups.keySet()) { + boolean isResource = ref.contains("://"); + String handlerName = findTargetMethodName(ref) + "CompletionHandler"; + String refObj = + isResource + ? "io.modelcontextprotocol.spec.McpSchema.ResourceReference" + : "io.modelcontextprotocol.spec.McpSchema.PromptReference"; - buffer - .append(CodeBlock.indent(8)) - .append("Object raw_") - .append(javaName) - .append(" = args != null ? args.get(\"") - .append(mcpName) - .append("\") : null;\n"); - buffer - .append(CodeBlock.indent(8)) - .append(type) - .append(" ") - .append(javaName) - .append(" = (") - .append(type) - .append(") raw_") - .append(javaName) - .append(";\n"); + if (kt) { + buffer.append( + statement( + indent(6), + "completions.add(io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(", + refObj, + "(", + string(ref), + "), this::", + handlerName, + "))")); + } else { + buffer.append( + statement( + indent(6), + "completions.add(new" + + " io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new" + + " ", + refObj, + "(", + string(ref), + "), this::", + handlerName, + "))", + semicolon(kt))); } - - String methodCall = - "c." + route.getMethodName() + "(" + String.join(", ", javaParamNames) + ")"; - buffer.append(CodeBlock.indent(8)).append("return ").append(methodCall).append(";\n"); - buffer.append(CodeBlock.indent(6)).append("}\n"); } + buffer.append(statement(indent(6), "return completions", semicolon(kt))); + buffer.append(statement(indent(4), "}\n")); - buffer.append(CodeBlock.indent(6)).append("default:\n"); - buffer - .append(CodeBlock.indent(8)) - .append( - "throw new IllegalArgumentException(\"Unknown MCP Resource Template: \" +" - + " templateUri);\n"); - buffer.append(CodeBlock.indent(4)).append("}\n"); - buffer.append(CodeBlock.indent(2)).append("}\n\n"); - - return buffer.toString(); - } - - private String generateCompletionDispatcher( - List completions, boolean kt, String generateTypeName) { - StringBuilder buffer = new StringBuilder(); - buffer.append(CodeBlock.indent(2)).append("@Override\n"); - buffer - .append(CodeBlock.indent(2)) - .append( - "public Object invokeCompletion(String identifier, String argumentName, String input," - + " io.modelcontextprotocol.server.McpSyncServerExchange exchange) throws Exception" - + " {\n"); - - if (completions.isEmpty()) { - buffer - .append(CodeBlock.indent(4)) - .append( - "throw new UnsupportedOperationException(\"invokeCompletion is not supported by this" - + " server\");\n"); - buffer.append(CodeBlock.indent(2)).append("}\n\n"); - return buffer.toString(); + // Generate install() + if (kt) { + buffer.append(statement(indent(4), "@Throws(Exception::class)")); + buffer.append( + statement( + indent(4), + "override fun install(app: io.jooby.Jooby, server:" + + " io.modelcontextprotocol.server.McpSyncServer, json:" + + " io.modelcontextprotocol.json.McpJsonMapper) {")); + buffer.append(statement(indent(6), "this.json = json")); + buffer.append( + statement( + indent(6), + "val mapper = app.require(tools.jackson.databind.ObjectMapper::class.java)")); + if (!tools.isEmpty()) { + buffer.append( + statement( + indent(6), + "val configBuilder =" + + " com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder(com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12," + + " com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON)")); + buffer.append( + statement( + indent(6), + "val schemaGenerator =" + + " com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build())")); + } + } else { + buffer.append(statement(indent(4), "@Override")); + buffer.append( + statement( + indent(4), + "public void install(io.jooby.Jooby app, io.modelcontextprotocol.server.McpSyncServer" + + " server, io.modelcontextprotocol.json.McpJsonMapper json) throws Exception" + + " {")); + buffer.append(statement(indent(6), "this.json = json", semicolon(kt))); + buffer.append( + statement( + indent(6), + "var mapper = app.require(tools.jackson.databind.ObjectMapper.class)", + semicolon(kt))); + if (!tools.isEmpty()) { + buffer.append( + statement( + indent(6), + "var configBuilder = new" + + " com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder(com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12," + + " com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON)", + semicolon(kt))); + buffer.append( + statement( + indent(6), + "var schemaGenerator = new" + + " com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build())", + semicolon(kt))); + } } - buffer - .append(CodeBlock.indent(4)) - .append( - "io.jooby.Context ctx = (io.jooby.Context)" - + " exchange.transportContext().get(\"CTX\");\n"); - buffer - .append(CodeBlock.indent(4)) - .append(generateTypeName) - .append(" c = factory.apply(ctx);\n\n"); - - buffer - .append(CodeBlock.indent(4)) - .append("String completionKey = identifier + \"_\" + argumentName;\n"); - buffer.append(CodeBlock.indent(4)).append("switch (completionKey) {\n"); - - for (MvcRoute route : completions) { - String ref = extractAnnotationValue(route, "io.jooby.annotation.McpCompletion", "ref"); - String arg = extractAnnotationValue(route, "io.jooby.annotation.McpCompletion", "arg"); - String key = ref + "_" + arg; - - buffer.append(CodeBlock.indent(6)).append("case \"").append(key).append("\": {\n"); - buffer - .append(CodeBlock.indent(8)) - .append("return c.") - .append(route.getMethodName()) - .append("(input);\n"); - buffer.append(CodeBlock.indent(6)).append("}\n"); + // FIXED: Filter now properly includes isMcpResourceTemplate() + for (var route : + getRoutes().stream() + .filter( + r -> + r.isMcpTool() + || r.isMcpPrompt() + || r.isMcpResource() + || r.isMcpResourceTemplate()) + .toList()) { + String methodName = route.getMethodName(); + + if (route.isMcpTool()) { + String defArgs = "mapper, schemaGenerator"; + if (kt) { + buffer.append( + statement( + indent(6), + "server.addTool(io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification(", + methodName, + "ToolSpec(", + defArgs, + "), this::", + methodName, + "))\n")); + } else { + buffer.append( + statement( + indent(6), + "server.addTool(new" + + " io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification(", + methodName, + "ToolSpec(", + defArgs, + "), this::", + methodName, + "))", + semicolon(kt), + "\n")); + } + } else if (route.isMcpPrompt()) { + if (kt) { + buffer.append( + statement( + indent(6), + "server.addPrompt(io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification(", + methodName, + "PromptSpec(), this::", + methodName, + "))\n")); + } else { + buffer.append( + statement( + indent(6), + "server.addPrompt(new" + + " io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification(", + methodName, + "PromptSpec(), this::", + methodName, + "))", + semicolon(kt), + "\n")); + } + } else if (route.isMcpResource() || route.isMcpResourceTemplate()) { + // FIXED: Condition now allows templates to execute this block! + boolean isTemplate = route.isMcpResourceTemplate(); + + String specType = + isTemplate ? "SyncResourceTemplateSpecification" : "SyncResourceSpecification"; + String addMethod = isTemplate ? "server.addResourceTemplate(" : "server.addResource("; + String defMethod = isTemplate ? "ResourceTemplateSpec()" : "ResourceSpec()"; + + if (kt) { + buffer.append( + statement( + indent(6), + addMethod, + "io.modelcontextprotocol.server.McpServerFeatures.", + specType, + "(", + methodName, + defMethod, + ", this::", + methodName, + "))\n")); + } else { + buffer.append( + statement( + indent(6), + addMethod, + "new io.modelcontextprotocol.server.McpServerFeatures.", + specType, + "(", + methodName, + defMethod, + ", this::", + methodName, + "))", + semicolon(kt), + "\n")); + } + } } + buffer.append(statement(indent(4), "}\n")); - buffer.append(CodeBlock.indent(6)).append("default:\n"); - buffer.append(CodeBlock.indent(8)).append("return java.util.List.of();\n"); - buffer.append(CodeBlock.indent(4)).append("}\n"); - buffer.append(CodeBlock.indent(2)).append("}\n\n"); - - return buffer.toString(); - } - - private String generateMcpDispatcher( - String dispatchMethod, List routes, boolean kt, String generateTypeName) { - StringBuilder buffer = new StringBuilder(); - buffer.append(CodeBlock.indent(2)).append("@Override\n"); - buffer - .append(CodeBlock.indent(2)) - .append("public Object ") - .append(dispatchMethod) - .append( - "(String name, java.util.Map args," - + " io.modelcontextprotocol.server.McpSyncServerExchange exchange) throws Exception" - + " {\n"); - - if (routes.isEmpty()) { - buffer - .append(CodeBlock.indent(4)) - .append("throw new UnsupportedOperationException(\"") - .append(dispatchMethod) - .append(" is not supported by this server\");\n"); - buffer.append(CodeBlock.indent(2)).append("}\n\n"); - return buffer.toString(); + // FIXED: Filter now properly includes isMcpResourceTemplate() + for (MvcRoute route : + getRoutes().stream() + .filter( + r -> + r.isMcpTool() + || r.isMcpPrompt() + || r.isMcpResource() + || r.isMcpResourceTemplate()) + .toList()) { + route.generateMcpDefinitionMethod(kt).forEach(buffer::append); + route.generateMcpHandlerMethod(kt).forEach(buffer::append); } - buffer - .append(CodeBlock.indent(4)) - .append( - "io.jooby.Context ctx = (io.jooby.Context)" - + " exchange.transportContext().get(\"CTX\");\n"); - buffer - .append(CodeBlock.indent(4)) - .append(generateTypeName) - .append(" c = factory.apply(ctx);\n"); - buffer - .append(CodeBlock.indent(4)) - .append( - "tools.jackson.databind.ObjectMapper mapper =" - + " ctx.require(tools.jackson.databind.ObjectMapper.class);\n\n"); - - buffer.append(CodeBlock.indent(4)).append("switch (name) {\n"); - - for (MvcRoute route : routes) { - String annotationClass; - String attributeName; - - if (dispatchMethod.equals("invokeTool")) { - annotationClass = "io.jooby.annotation.McpTool"; - attributeName = "name"; - } else if (dispatchMethod.equals("invokePrompt")) { - annotationClass = "io.jooby.annotation.McpPrompt"; - attributeName = "name"; - } else if (dispatchMethod.equals("readResource")) { - annotationClass = "io.jooby.annotation.McpResource"; - attributeName = "value"; + // --- STEP 3: GENERATE THE UNIFIED COMPLETION HANDLERS (THE ROUTER) --- + for (var entry : completionGroups.entrySet()) { + String ref = entry.getKey(); + String handlerName = findTargetMethodName(ref) + "CompletionHandler"; + java.util.List routes = entry.getValue(); + + if (kt) { + buffer.append( + statement( + indent(4), + "private fun ", + handlerName, + "(exchange: io.modelcontextprotocol.server.McpSyncServerExchange, req:" + + " io.modelcontextprotocol.spec.McpSchema.CompleteRequest):" + + " io.modelcontextprotocol.spec.McpSchema.CompleteResult {")); + buffer.append( + statement( + indent(6), + "val ctx =" + + " exchange.transportContext().get(io.jooby.Context::class.java.name)")); + buffer.append(statement(indent(6), "val c = this.factory.apply(ctx)")); + buffer.append(statement(indent(6), "val targetArg = req.argument()?.name() ?: \"\"")); + buffer.append(statement(indent(6), "val typedValue = req.argument()?.value() ?: \"\"")); + buffer.append(statement(indent(6), "return when (targetArg) {")); } else { - throw new IllegalStateException("Unsupported dispatch method: " + dispatchMethod); + buffer.append( + statement( + indent(4), + "private io.modelcontextprotocol.spec.McpSchema.CompleteResult ", + handlerName, + "(io.modelcontextprotocol.server.McpSyncServerExchange exchange," + + " io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) {")); + buffer.append( + statement( + indent(6), + "var ctx = (io.jooby.Context) exchange.transportContext().get(\"CTX\")", + semicolon(kt))); + buffer.append(statement(indent(6), "var c = this.factory.apply(ctx)", semicolon(kt))); + buffer.append( + statement( + indent(6), + "var targetArg = req.argument() != null ? req.argument().name() : \"\"", + semicolon(kt))); + buffer.append( + statement( + indent(6), + "var typedValue = req.argument() != null ? req.argument().value() : \"\"", + semicolon(kt))); + buffer.append(statement(indent(6), "return switch (targetArg) {")); } - String routeName = extractAnnotationValue(route, annotationClass, attributeName); - if (routeName.isEmpty()) routeName = route.getMethodName(); - - buffer.append(CodeBlock.indent(6)).append("case \"").append(routeName).append("\": {\n"); + for (MvcRoute route : routes) { + String targetArgName = null; + java.util.List invokeArgs = new java.util.ArrayList<>(); - List javaParamNames = new ArrayList<>(); - for (MvcParameter param : route.getParameters(false)) { - String javaName = param.getName(); - String mcpName = param.getMcpName(); - String type = param.getType().getRawType().toString(); - javaParamNames.add(javaName); - - if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange")) { - buffer - .append(CodeBlock.indent(8)) - .append(type) - .append(" ") - .append(javaName) - .append(" = exchange;\n"); - continue; - } - if (type.equals("io.jooby.Context")) { - buffer - .append(CodeBlock.indent(8)) - .append(type) - .append(" ") - .append(javaName) - .append(" = ctx;\n"); - continue; + for (var param : route.getParameters(false)) { + String type = param.getType().getRawType().toString(); + if (type.equals("io.jooby.Context")) { + invokeArgs.add("ctx"); + } else if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange")) { + invokeArgs.add("exchange"); + } else { + targetArgName = param.getMcpName(); + invokeArgs.add("typedValue"); + } } - buffer - .append(CodeBlock.indent(8)) - .append("Object raw_") - .append(javaName) - .append(" = args != null ? args.get(\"") - .append(mcpName) - .append("\") : null;\n"); - - switch (type) { - case "java.lang.String": - buffer - .append(CodeBlock.indent(8)) - .append(type) - .append(" ") - .append(javaName) - .append(" = (String) raw_") - .append(javaName) - .append(";\n"); - break; - case "int": - case "java.lang.Integer": - buffer - .append(CodeBlock.indent(8)) - .append(type) - .append(" ") - .append(javaName) - .append(" = raw_") - .append(javaName) - .append(" == null ? 0 : ((Number) raw_") - .append(javaName) - .append(").intValue();\n"); - break; - case "double": - case "java.lang.Double": - buffer - .append(CodeBlock.indent(8)) - .append(type) - .append(" ") - .append(javaName) - .append(" = raw_") - .append(javaName) - .append(" == null ? 0.0 : ((Number) raw_") - .append(javaName) - .append(").doubleValue();\n"); - break; - case "boolean": - case "java.lang.Boolean": - buffer - .append(CodeBlock.indent(8)) - .append(type) - .append(" ") - .append(javaName) - .append(" = raw_") - .append(javaName) - .append(" == null ? false : (Boolean) raw_") - .append(javaName) - .append(";\n"); - break; - default: - buffer - .append(CodeBlock.indent(8)) - .append(type) - .append(" ") - .append(javaName) - .append(" = raw_") - .append(javaName) - .append(" == null ? null : mapper.convertValue(raw_") - .append(javaName) - .append(", ") - .append(type) - .append(".class);\n"); - break; + if (targetArgName == null) continue; + + if (kt) { + buffer.append(statement(indent(8), string(targetArgName), " -> {")); + buffer.append( + statement( + indent(10), + "val result = c.", + route.getMethodName(), + "(", + String.join(", ", invokeArgs), + ")")); + buffer.append( + statement(indent(10), "io.jooby.mcp.McpResult(this.json).toCompleteResult(result)")); + buffer.append(statement(indent(8), "}")); + } else { + buffer.append(statement(indent(8), "case ", string(targetArgName), " -> {")); + buffer.append( + statement( + indent(10), + "var result = c.", + route.getMethodName(), + "(", + String.join(", ", invokeArgs), + ")", + semicolon(kt))); + buffer.append( + statement( + indent(10), + "yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result)", + semicolon(kt))); + buffer.append(statement(indent(8), "}")); } } - String methodCall = - "c." + route.getMethodName() + "(" + String.join(", ", javaParamNames) + ")"; - if (route.getReturnType().isVoid()) { - buffer.append(CodeBlock.indent(8)).append(methodCall).append(";\n"); - buffer.append(CodeBlock.indent(8)).append("return null;\n"); + // Default fallback returning the empty list + if (kt) { + buffer.append( + statement( + indent(8), + "else -> io.jooby.mcp.McpResult(this.json).toCompleteResult(emptyList())")); + buffer.append(statement(indent(6), "}")); } else { - buffer.append(CodeBlock.indent(8)).append("return ").append(methodCall).append(";\n"); + buffer.append( + statement( + indent(8), + "default -> new" + + " io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of())", + semicolon(kt))); + buffer.append( + statement( + indent(6), + "}", + semicolon(kt))); // Note: The semicolon here closes the return statement! } - buffer.append(CodeBlock.indent(6)).append("}\n"); + buffer.append(statement(indent(4), "}\n")); } - buffer.append(CodeBlock.indent(6)).append("default:\n"); - buffer - .append(CodeBlock.indent(8)) - .append("throw new IllegalArgumentException(\"Unknown MCP entity for \" + \"") - .append(dispatchMethod) - .append("\" + \": \" + name);\n"); - buffer.append(CodeBlock.indent(4)).append("}\n"); - buffer.append(CodeBlock.indent(2)).append("}\n\n"); + return template + .replace("${packageName}", packageName) + .replace("${imports}", imports) + .replace("${className}", generateTypeName) + .replace("${generatedClassName}", mcpClassName) + .replace("${implements}", "io.jooby.mcp.McpService") + .replace("${constructors}", constructors(mcpClassName, kt)) + .replace("${methods}", trimr(buffer)); + } - return buffer.toString(); + private String findTargetMethodName(String ref) { + for (MvcRoute route : getRoutes()) { + if (route.isMcpPrompt()) { + String name = extractAnnotationValue(route, "io.jooby.annotation.McpPrompt", "name"); + if (name == null || name.isEmpty()) name = route.getMethodName(); + if (ref.equals(name)) return route.getMethodName(); + } else if (route.isMcpResource() || route.isMcpResourceTemplate()) { + // Now checks BOTH route types, but only reads from @McpResource + String uri = extractAnnotationValue(route, "io.jooby.annotation.McpResource", "value"); + if (ref.equals(uri)) return route.getMethodName(); + } + } + return "mcpTarget" + Math.abs(ref.hashCode()); + } + + private StringBuilder trimr(StringBuilder buffer) { + var i = buffer.length() - 1; + while (i > 0 && Character.isWhitespace(buffer.charAt(i))) { + buffer.deleteCharAt(i); + i = buffer.length() - 1; + } + return buffer; } private String extractAnnotationValue(MvcRoute route, String annotationName, String attribute) { @@ -924,15 +1042,6 @@ private String extractAnnotationValue(MvcRoute route, String annotationName, Str .orElse(""); } - private StringBuilder trimr(StringBuilder buffer) { - var i = buffer.length() - 1; - while (i > 0 && Character.isWhitespace(buffer.charAt(i))) { - buffer.deleteCharAt(i); - i = buffer.length() - 1; - } - return buffer; - } - private StringBuilder constructors(String generatedName, boolean kt) { var constructors = getTargetType().getEnclosedElements().stream() diff --git a/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java index 1eadb9d560..681ceeb5bb 100644 --- a/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java +++ b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java @@ -20,6 +20,13 @@ public int add(@McpParam(name = "a") int a, @McpParam(name = "b") int b) { } // 2. Prompt + /** + * Reviews the given code snippet in the context of the specified programming language. + * + * @param language the programming language of the code to be reviewed + * @param code the code snippet that needs to be reviewed + * @return a string containing the prompt to review the provided code + */ @McpPrompt(name = "review_code") public String reviewCode( @McpParam(name = "language") String language, @McpParam(name = "code") String code) { @@ -33,15 +40,25 @@ public String getLogs() { } // 4. Resource Template - @McpResource("file:///users/{id}/profile") - public Map getUserProfile(@McpParam(name = "id") String id) { + @McpResource("file:///users/{id}/{name}/profile") + public Map getUserProfile(String id) { return Map.of("id", id, "name", "John Doe"); } // 5. Completion (Linked to the Resource Template 'id' argument) - @McpCompletion(ref = "file:///users/{id}/profile", arg = "id") - public List completeUserId(String input) { - // In reality, this might filter a database based on the 'input' prefix + @McpCompletion(ref = "file:///users/{id}/{name}/profile") + public List completeUserId(@McpParam(name = "id") String input) { return List.of("123", "456", "789"); } + + // 5. Completion (Linked to the Resource Template 'id' argument) + @McpCompletion(ref = "file:///users/{id}/{name}/profile") + public List completeUserName(String name) { + return List.of("username", "userid"); + } + + @McpCompletion(ref = "review_code") + public List reviewCodelanguage(String language) { + return List.of("Java", "Kotlin"); + } } diff --git a/modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java deleted file mode 100644 index cc77462111..0000000000 --- a/modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java +++ /dev/null @@ -1,8 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package tests.i3830; - -public class ExampleServerMcp_ implements io.jooby.mcp.McpService {} diff --git a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java index e0d269a8e7..3d882986dc 100644 --- a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java +++ b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java @@ -5,6 +5,8 @@ */ package tests.i3830; +import static org.assertj.core.api.Assertions.assertThat; + import org.junit.jupiter.api.Test; import io.jooby.apt.ProcessorRunner; @@ -15,7 +17,180 @@ public void shouldGenerateMcpServer() throws Exception { new ProcessorRunner(new ExampleServer()) .withSourceCode( source -> { - System.out.println(source); + assertThat(source) + .isEqualToNormalizingWhitespace( + """ + package tests.i3830; + + @io.jooby.annotation.Generated(ExampleServer.class) + public class ExampleServerMcp_ implements io.jooby.mcp.McpService { + protected java.util.function.Function factory; + + public ExampleServerMcp_() { + this(io.jooby.SneakyThrows.singleton(ExampleServer::new)); + } + + public ExampleServerMcp_(ExampleServer instance) { + setup(ctx -> instance); + } + + public ExampleServerMcp_(io.jooby.SneakyThrows.Supplier provider) { + setup(ctx -> (ExampleServer) provider.get()); + } + + public ExampleServerMcp_(io.jooby.SneakyThrows.Function, ExampleServer> provider) { + setup(ctx -> provider.apply(ExampleServer.class)); + } + + private void setup(java.util.function.Function factory) { + this.factory = factory; + } + + private io.modelcontextprotocol.json.McpJsonMapper json; + + @Override + public void capabilities(io.modelcontextprotocol.spec.McpSchema.ServerCapabilities.Builder capabilities) { + capabilities.tools(true); + capabilities.prompts(true); + capabilities.resources(true, true); + } + + @Override + public String serverName() { + return "example-server"; + } + + @Override + public java.util.List completions() { + var completions = new java.util.ArrayList(); + completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.ResourceReference("file:///users/{id}/{name}/profile"), this::getUserProfileCompletionHandler)); + completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), this::reviewCodeCompletionHandler)); + return completions; + } + + @Override + public void install(io.jooby.Jooby app, io.modelcontextprotocol.server.McpSyncServer server, io.modelcontextprotocol.json.McpJsonMapper json) throws Exception { + this.json = json; + var mapper = app.require(tools.jackson.databind.ObjectMapper.class); + var configBuilder = new com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder(com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12, com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON); + var schemaGenerator = new com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build()); + server.addTool(new io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification(addToolSpec(mapper, schemaGenerator), this::add)); + + server.addPrompt(new io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification(reviewCodePromptSpec(), this::reviewCode)); + + server.addResource(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification(getLogsResourceSpec(), this::getLogs)); + + server.addResourceTemplate(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceTemplateSpecification(getUserProfileResourceTemplateSpec(), this::getUserProfile)); + + } + + private io.modelcontextprotocol.spec.McpSchema.Tool addToolSpec(tools.jackson.databind.ObjectMapper mapper, com.github.victools.jsonschema.generator.SchemaGenerator schemaGenerator) { + var schema = mapper.createObjectNode(); + schema.put("type", "object"); + var props = schema.putObject("properties"); + var req = schema.putArray("required"); + props.set("a", schemaGenerator.generateSchema(int.class)); + req.add("a"); + props.set("b", schemaGenerator.generateSchema(int.class)); + req.add("b"); + return new io.modelcontextprotocol.spec.McpSchema.Tool("calculator", null, "A simple calculator", mapper.treeToValue(schema, io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), null, null, null); + } + + private io.modelcontextprotocol.spec.McpSchema.CallToolResult add(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.spec.McpSchema.CallToolRequest req) { + var ctx = (io.jooby.Context) exchange.transportContext().get("CTX"); + var args = req.arguments() != null ? req.arguments() : java.util.Collections.emptyMap(); + var c = this.factory.apply(ctx); + var raw_a = args.get("a"); + if (raw_a == null) throw new IllegalArgumentException("Missing req param: a"); + var a = raw_a instanceof Number ? ((Number) raw_a).intValue() : Integer.parseInt(raw_a.toString()); + var raw_b = args.get("b"); + if (raw_b == null) throw new IllegalArgumentException("Missing req param: b"); + var b = raw_b instanceof Number ? ((Number) raw_b).intValue() : Integer.parseInt(raw_b.toString()); + var result = c.add(a, b); + return new io.jooby.mcp.McpResult(this.json).toCallToolResult(result); + } + + private io.modelcontextprotocol.spec.McpSchema.Prompt reviewCodePromptSpec() { + var args = new java.util.ArrayList(); + args.add(new io.modelcontextprotocol.spec.McpSchema.PromptArgument("language", null, false)); + args.add(new io.modelcontextprotocol.spec.McpSchema.PromptArgument("code", null, false)); + return new io.modelcontextprotocol.spec.McpSchema.Prompt("review_code", null, "", args); + } + + private io.modelcontextprotocol.spec.McpSchema.GetPromptResult reviewCode(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.spec.McpSchema.GetPromptRequest req) { + var ctx = (io.jooby.Context) exchange.transportContext().get("CTX"); + var args = req.arguments() != null ? req.arguments() : java.util.Collections.emptyMap(); + var c = this.factory.apply(ctx); + var raw_language = args.get("language"); + var language = raw_language != null ? raw_language.toString() : null; + var raw_code = args.get("code"); + var code = raw_code != null ? raw_code.toString() : null; + var result = c.reviewCode(language, code); + return new io.jooby.mcp.McpResult(this.json).toPromptResult(result); + } + + private io.modelcontextprotocol.spec.McpSchema.Resource getLogsResourceSpec() { + return new io.modelcontextprotocol.spec.McpSchema.Resource("file:///logs/app.log", "getLogs", null, "", null, null, null, null); + } + + private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getLogs(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest req) { + var ctx = (io.jooby.Context) exchange.transportContext().get("CTX"); + var args = java.util.Collections.emptyMap(); + var c = this.factory.apply(ctx); + var result = c.getLogs(); + return new io.jooby.mcp.McpResult(this.json).toResourceResult(result); + } + + private io.modelcontextprotocol.spec.McpSchema.ResourceTemplate getUserProfileResourceTemplateSpec() { + return new io.modelcontextprotocol.spec.McpSchema.ResourceTemplate("file:///users/{id}/{name}/profile", "getUserProfile", null, "", null, null, null); + } + + private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getUserProfile(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest req) { + var ctx = (io.jooby.Context) exchange.transportContext().get("CTX"); + var uri = req.uri(); + var manager = new io.modelcontextprotocol.util.DefaultMcpUriTemplateManager("file:///users/{id}/{name}/profile"); + var args = new java.util.HashMap(); + args.putAll(manager.extractVariableValues(uri)); + var c = this.factory.apply(ctx); + var raw_id = args.get("id"); + var id = raw_id != null ? raw_id.toString() : null; + var result = c.getUserProfile(id); + return new io.jooby.mcp.McpResult(this.json).toResourceResult(result); + } + + private io.modelcontextprotocol.spec.McpSchema.CompleteResult getUserProfileCompletionHandler(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) { + var ctx = (io.jooby.Context) exchange.transportContext().get("CTX"); + var c = this.factory.apply(ctx); + var targetArg = req.argument() != null ? req.argument().name() : ""; + var typedValue = req.argument() != null ? req.argument().value() : ""; + return switch (targetArg) { + case "id" -> { + var result = c.completeUserId(typedValue); + yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); + } + case "name" -> { + var result = c.completeUserName(typedValue); + yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); + } + default -> new io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of()); + }; + } + + private io.modelcontextprotocol.spec.McpSchema.CompleteResult reviewCodeCompletionHandler(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) { + var ctx = (io.jooby.Context) exchange.transportContext().get("CTX"); + var c = this.factory.apply(ctx); + var targetArg = req.argument() != null ? req.argument().name() : ""; + var typedValue = req.argument() != null ? req.argument().value() : ""; + return switch (targetArg) { + case "language" -> { + var result = c.reviewCodelanguage(typedValue); + yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); + } + default -> new io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of()); + }; + } + } + """); }); } } From 81ad12c5e1c16135fcd7b1fbbebb70be3c4e980b Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Wed, 25 Mar 2026 13:48:33 -0300 Subject: [PATCH 05/37] build: apt: refactor: split code generation - into: rest, jsonrpc, trpc and mcp --- .../java/io/jooby/apt/JoobyProcessor.java | 137 ++-- .../io/jooby/internal/apt/JsonRpcRoute.java | 237 ++++++ .../io/jooby/internal/apt/JsonRpcRouter.java | 272 +++++++ .../java/io/jooby/internal/apt/McpRoute.java | 682 ++++++++++++++++++ .../java/io/jooby/internal/apt/McpRouter.java | 545 ++++++++++++++ .../io/jooby/internal/apt/MvcContext.java | 13 +- .../io/jooby/internal/apt/MvcParameter.java | 4 +- .../java/io/jooby/internal/apt/MvcRoute.java | 6 +- .../java/io/jooby/internal/apt/MvcRouter.java | 6 +- .../internal/apt/ParameterGenerator.java | 10 +- .../java/io/jooby/internal/apt/RestRoute.java | 383 ++++++++++ .../io/jooby/internal/apt/RestRouter.java | 149 ++++ .../apt/RouteAttributesGenerator.java | 2 +- .../java/io/jooby/internal/apt/TrpcRoute.java | 341 +++++++++ .../io/jooby/internal/apt/TrpcRouter.java | 120 +++ .../java/io/jooby/internal/apt/WebRoute.java | 139 ++++ .../java/io/jooby/internal/apt/WebRouter.java | 323 +++++++++ .../java/io/jooby/apt/ProcessorRunner.java | 2 +- .../test/java/tests/HandlerCompilerTest.java | 1 + .../src/test/java/tests/i3804/Issue3804.java | 1 - 20 files changed, 3272 insertions(+), 101 deletions(-) create mode 100644 modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRoute.java create mode 100644 modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRouter.java create mode 100644 modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java create mode 100644 modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java create mode 100644 modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java create mode 100644 modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRouter.java create mode 100644 modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRoute.java create mode 100644 modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRouter.java create mode 100644 modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRoute.java create mode 100644 modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRouter.java diff --git a/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java b/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java index 3ec285b672..85732607de 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java +++ b/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java @@ -129,87 +129,51 @@ public boolean process(Set annotations, RoundEnvironment try { if (roundEnv.processingOver()) { context.debug("Output:"); - // Print all generated types for both REST and RPC - context - .getRouters() - .forEach( - it -> { - if (it.hasRestRoutes()) { - context.debug(" %s", it.getRestGeneratedType()); - } - if (it.hasJsonRpcRoutes()) { - context.debug(" %s", it.getRpcGeneratedType()); - } - }); + context.getRouters().forEach(it -> context.debug(" %s", it.getGeneratedType())); return false; } else { - var routeMap = buildRouteRegistry(annotations, roundEnv); - verifyBeanValidationDependency(routeMap.values()); - for (var router : routeMap.values()) { + // 1. Discover all unique Controller classes + Set controllers = findControllers(annotations, roundEnv); + + // 2. Factory Pattern: Build specific routers for each class based on method annotations + List> activeRouters = new ArrayList<>(); + for (TypeElement controller : controllers) { + if (controller.getModifiers().contains(Modifier.ABSTRACT)) continue; + + // These factory methods will scan the class methods and return a populated router + // if it finds relevant annotations (@GET for Rest, @McpTool for MCP, etc.) + // We will implement these factories inside the respective Router classes. + + RestRouter restRouter = RestRouter.parse(context, controller); + if (!restRouter.isEmpty()) activeRouters.add(restRouter); + + JsonRpcRouter jsonRpcRouter = JsonRpcRouter.parse(context, controller); + if (!jsonRpcRouter.isEmpty()) activeRouters.add(jsonRpcRouter); + + McpRouter mcpRouter = McpRouter.parse(context, controller); + if (!mcpRouter.isEmpty()) activeRouters.add(mcpRouter); + + TrpcRouter trpcRouter = TrpcRouter.parse(context, controller); + if (!trpcRouter.isEmpty()) activeRouters.add(trpcRouter); + } + + verifyBeanValidationDependency(activeRouters); + + // 3. Generate Code Iteratively! + for (WebRouter router : activeRouters) { try { - // Track the router unconditionally so routes are available in processingOver - context.add(router); - - // 1. Generate Standard REST/tRPC File (e.g., MovieService_.java) - if (router.hasRestRoutes()) { - var restSource = router.getRestSourceCode(null); - if (restSource != null) { - var sourceLocation = router.getRestGeneratedFilename(); - var generatedType = router.getRestGeneratedType(); - onGeneratedSource(generatedType, toJavaFileObject(sourceLocation, restSource)); - - context.debug("router %s: %s", router.getTargetType(), generatedType); - router.getRoutes().stream() - .filter(it -> !it.isJsonRpc()) - .forEach(it -> context.debug(" %s", it)); - - writeSource( - router.isKt(), - generatedType, - sourceLocation, - restSource, - router.getTargetType()); - } - } + context.add(router); // Track for processingOver output - // 2. Generate JSON-RPC File (e.g., MovieServiceRpc_.java) - if (router.hasJsonRpcRoutes()) { - var rpcSource = router.getRpcSourceCode(null); - if (rpcSource != null) { - var sourceLocation = router.getRpcGeneratedFilename(); - var generatedType = router.getRpcGeneratedType(); - onGeneratedSource(generatedType, toJavaFileObject(sourceLocation, rpcSource)); - - context.debug("jsonrpc router %s: %s", router.getTargetType(), generatedType); - router.getRoutes().stream() - .filter(MvcRoute::isJsonRpc) - .forEach(it -> context.debug(" %s", it)); - - writeSource( - router.isKt(), - generatedType, - sourceLocation, - rpcSource, - router.getTargetType()); - } - } - // 3. Generate MCP Server File (e.g., WeatherServerMcp_.java) - if (router.hasMcpRoutes()) { - var mcpSource = router.getMcpSourceCode(null); - if (mcpSource != null) { - var sourceLocation = router.getMcpGeneratedFilename(); - var generatedType = router.getMcpGeneratedType(); - onGeneratedSource(generatedType, toJavaFileObject(sourceLocation, mcpSource)); - - context.debug("mcp router %s: %s", router.getTargetType(), generatedType); - - writeSource( - router.isKt(), - generatedType, - sourceLocation, - mcpSource, - router.getTargetType()); - } + String sourceCode = router.getSourceCode(null); + if (sourceCode != null) { + String sourceLocation = router.getGeneratedFilename(); + String generatedType = router.getGeneratedType(); + + onGeneratedSource(generatedType, toJavaFileObject(sourceLocation, sourceCode)); + context.debug("router %s: %s", router.getTargetType(), generatedType); + + writeSource( + router.isKt(), generatedType, sourceLocation, sourceCode, router.getTargetType()); } } catch (IOException cause) { @@ -225,6 +189,21 @@ public boolean process(Set annotations, RoundEnvironment } } + private Set findControllers( + Set annotations, RoundEnvironment roundEnv) { + Set controllers = new LinkedHashSet<>(); + for (var annotation : annotations) { + for (var element : roundEnv.getElementsAnnotatedWith(annotation)) { + if (element instanceof TypeElement typeElement) { + controllers.add(typeElement); + } else if (element instanceof ExecutableElement method) { + controllers.add((TypeElement) method.getEnclosingElement()); + } + } + } + return controllers; + } + private void writeSource( boolean isKt, String className, @@ -492,8 +471,8 @@ private static E sneakyThrow0(final Throwable x) throws E throw (E) x; } - private void verifyBeanValidationDependency(Collection routers) { - var hasBeanValidation = routers.stream().anyMatch(MvcRouter::hasBeanValidation); + private void verifyBeanValidationDependency(Collection> routers) { + var hasBeanValidation = routers.stream().anyMatch(WebRouter::hasBeanValidation); if (hasBeanValidation) { var missingDependency = Stream.of( diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRoute.java new file mode 100644 index 0000000000..c39d6aef67 --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRoute.java @@ -0,0 +1,237 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.apt; + +import static io.jooby.internal.apt.AnnotationSupport.VALUE; +import static io.jooby.internal.apt.CodeBlock.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.StringJoiner; +import java.util.function.Consumer; + +import javax.lang.model.element.ExecutableElement; + +public class JsonRpcRoute extends WebRoute { + + public JsonRpcRoute(WebRouter router, ExecutableElement method) { + super(router, method); + } + + public String getJsonRpcMethodName() { + var annotation = AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.JsonRpc"); + if (annotation != null) { + var val = + AnnotationSupport.findAnnotationValue(annotation, VALUE).stream().findFirst().orElse(""); + if (!val.isEmpty()) return val; + } + return getMethodName(); + } + + public List generateJsonRpcDispatchCase(boolean kt) { + var buffer = new ArrayList(); + var paramList = new StringJoiner(", ", "(", ")"); + + // Check if we have any parameters that actually need to be parsed from the JSON payload + boolean needsReader = + parameters.stream() + .anyMatch( + p -> { + String type = p.getType().toString(); + return !type.equals("io.jooby.Context") + && !type.startsWith("kotlin.coroutines.Continuation"); + }); + + if (needsReader) { + if (kt) { + buffer.add(statement(indent(8), "parser.reader(req.params).use { reader ->")); + } else { + buffer.add(statement(indent(8), "try (var reader = parser.reader(req.getParams())) {")); + } + } + + buffer.addAll(generateRpcParameter(kt, paramList::add)); + + int callIndent = needsReader ? 10 : 8; + var call = CodeBlock.of("c.", getMethodName(), paramList.toString()); + + if (returnType.isVoid()) { + buffer.add(statement(indent(callIndent), call, semicolon(kt))); + buffer.add(statement(indent(callIndent), kt ? "null" : "return null", semicolon(kt))); + } else { + buffer.add(statement(indent(callIndent), kt ? call : "return " + call, semicolon(kt))); + } + + if (needsReader) { + buffer.add(statement(indent(8), "}")); + } + + return buffer; + } + + private List generateRpcParameter(boolean kt, Consumer arguments) { + var statements = new ArrayList(); + var decoderInterface = "io.jooby.rpc.jsonrpc.JsonRpcDecoder"; + int baseIndent = 10; + + for (var parameter : parameters) { + var paramenterName = parameter.getName(); + var type = type(kt, parameter.getType().toString()); + boolean isNullable = parameter.isNullable(kt); + + switch (parameter.getType().getRawType().toString()) { + case "io.jooby.Context": + arguments.accept("ctx"); + break; + case "int", + "long", + "double", + "boolean", + "java.lang.String", + "java.lang.Integer", + "java.lang.Long", + "java.lang.Double", + "java.lang.Boolean": + var simpleType = type.startsWith("java.lang.") ? type.substring(10) : type; + if (simpleType.equals("Integer") || simpleType.equals("int")) simpleType = "Int"; + var readName = + "next" + Character.toUpperCase(simpleType.charAt(0)) + simpleType.substring(1); + + if (isNullable) { + if (kt) { + statements.add( + statement( + indent(baseIndent), + "val ", + paramenterName, + " = if (reader.nextIsNull(", + string(paramenterName), + ")) null else reader.", + readName, + "(", + string(paramenterName), + ")")); + } else { + statements.add( + statement( + indent(baseIndent), + var(kt), + paramenterName, + " = reader.nextIsNull(", + string(paramenterName), + ") ? null : reader.", + readName, + "(", + string(paramenterName), + ")", + semicolon(kt))); + } + } else { + statements.add( + statement( + indent(baseIndent), + var(kt), + paramenterName, + " = reader.", + readName, + "(", + string(paramenterName), + ")", + semicolon(kt))); + } + arguments.accept(paramenterName); + break; + default: + if (kt) { + statements.add( + statement( + indent(baseIndent), + "val ", + paramenterName, + "Decoder: ", + decoderInterface, + "<", + type, + "> = parser.decoder(", + parameter.getType().toSourceCode(kt), + ")", + semicolon(kt))); + if (isNullable) { + statements.add( + statement( + indent(baseIndent), + "val ", + paramenterName, + " = if (reader.nextIsNull(", + string(paramenterName), + ")) null else reader.nextObject(", + string(paramenterName), + ", ", + paramenterName, + "Decoder)")); + } else { + statements.add( + statement( + indent(baseIndent), + "val ", + paramenterName, + " = reader.nextObject(", + string(paramenterName), + ", ", + paramenterName, + "Decoder)", + semicolon(kt))); + } + } else { + statements.add( + statement( + indent(baseIndent), + decoderInterface, + "<", + type, + "> ", + paramenterName, + "Decoder = parser.decoder(", + parameter.getType().toSourceCode(kt), + ")", + semicolon(kt))); + if (isNullable) { + statements.add( + statement( + indent(baseIndent), + parameter.getType().toString(), + " ", + paramenterName, + " = reader.nextIsNull(", + string(paramenterName), + ") ? null : reader.nextObject(", + string(paramenterName), + ", ", + paramenterName, + "Decoder)", + semicolon(kt))); + } else { + statements.add( + statement( + indent(baseIndent), + parameter.getType().toString(), + " ", + paramenterName, + " = reader.nextObject(", + string(paramenterName), + ", ", + paramenterName, + "Decoder)", + semicolon(kt))); + } + } + arguments.accept(paramenterName); + break; + } + } + return statements; + } +} diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRouter.java new file mode 100644 index 0000000000..bbc0bfbcf4 --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRouter.java @@ -0,0 +1,272 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.apt; + +import static io.jooby.internal.apt.AnnotationSupport.VALUE; +import static io.jooby.internal.apt.CodeBlock.*; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.TypeElement; + +public class JsonRpcRouter extends WebRouter { + + public JsonRpcRouter(MvcContext context, TypeElement clazz) { + super(context, clazz); + } + + public static JsonRpcRouter parse(MvcContext context, TypeElement controller) { + JsonRpcRouter router = new JsonRpcRouter(context, controller); + var classJsonRpcAnno = + AnnotationSupport.findAnnotationByName(controller, "io.jooby.annotation.JsonRpc"); + + List explicitlyAnnotated = new ArrayList<>(); + List allPublicMethods = new ArrayList<>(); + + for (var enclosed : controller.getEnclosedElements()) { + if (enclosed.getKind() == ElementKind.METHOD) { + var method = (ExecutableElement) enclosed; + var modifiers = method.getModifiers(); + + if (modifiers.contains(Modifier.PUBLIC) + && !modifiers.contains(Modifier.STATIC) + && !modifiers.contains(Modifier.ABSTRACT)) { + String methodName = method.getSimpleName().toString(); + if (methodName.equals("toString") + || methodName.equals("hashCode") + || methodName.equals("equals") + || methodName.equals("clone")) continue; + + allPublicMethods.add(method); + if (AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.JsonRpc") + != null) { + explicitlyAnnotated.add(method); + } + } + } + } + + if (!explicitlyAnnotated.isEmpty()) { + for (var method : explicitlyAnnotated) { + JsonRpcRoute route = new JsonRpcRoute(router, method); + router.routes.put(route.getMethodName(), route); + } + } else if (classJsonRpcAnno != null) { + for (var method : allPublicMethods) { + JsonRpcRoute route = new JsonRpcRoute(router, method); + router.routes.put(route.getMethodName(), route); + } + } + return router; + } + + @Override + public String getGeneratedType() { + return context.generateRouterName(getTargetType().getQualifiedName().toString() + "Rpc"); + } + + public String getJsonRpcNamespace() { + var annotation = AnnotationSupport.findAnnotationByName(clazz, "io.jooby.annotation.JsonRpc"); + if (annotation != null) { + return AnnotationSupport.findAnnotationValue(annotation, VALUE).stream() + .findFirst() + .orElse(""); + } + return ""; + } + + @Override + public String getSourceCode(Boolean generateKotlin) throws IOException { + if (isEmpty()) return null; + + boolean kt = generateKotlin == Boolean.TRUE || isKt(); + var generateTypeName = getTargetType().getSimpleName().toString(); + var generatedClass = getGeneratedType().substring(getGeneratedType().lastIndexOf('.') + 1); + var namespace = getJsonRpcNamespace(); + + var template = kt ? KOTLIN : JAVA; + var buffer = new StringBuilder(); + + context.generateStaticImports( + this, + (owner, fn) -> + buffer.append( + statement("import ", kt ? "" : "static ", owner, ".", fn, semicolon(kt)))); + var imports = buffer.toString(); + buffer.setLength(0); + + List fullMethods = new ArrayList<>(); + for (JsonRpcRoute route : getRoutes()) { + String routeName = route.getJsonRpcMethodName(); + fullMethods.add(namespace.isEmpty() ? routeName : namespace + "." + routeName); + } + + String methodListString = + fullMethods.stream().map(m -> "\"" + m + "\"").collect(Collectors.joining(", ")); + + if (kt) { + buffer.append(indent(4)).append("@Throws(Exception::class)").append(System.lineSeparator()); + buffer + .append(indent(4)) + .append("override fun install(app: io.jooby.Jooby) {") + .append(System.lineSeparator()); + buffer + .append(indent(6)) + .append("app.services.listOf(io.jooby.rpc.jsonrpc.JsonRpcService::class.java).add(this)") + .append(System.lineSeparator()); + buffer + .append(indent(4)) + .append("}") + .append(System.lineSeparator()) + .append(System.lineSeparator()); + + buffer + .append(indent(4)) + .append("override fun getMethods(): List {") + .append(System.lineSeparator()); + buffer + .append(indent(6)) + .append("return listOf(") + .append(methodListString) + .append(")") + .append(System.lineSeparator()); + buffer + .append(indent(4)) + .append("}") + .append(System.lineSeparator()) + .append(System.lineSeparator()); + + buffer + .append(indent(4)) + .append( + "override fun execute(ctx: io.jooby.Context, req:" + + " io.jooby.rpc.jsonrpc.JsonRpcRequest): Any? {") + .append(System.lineSeparator()); + buffer.append(indent(6)).append("val c = factory.apply(ctx)").append(System.lineSeparator()); + buffer.append(indent(6)).append("val method = req.method").append(System.lineSeparator()); + buffer + .append(indent(6)) + .append("val parser = ctx.require(io.jooby.rpc.jsonrpc.JsonRpcParser::class.java)") + .append(System.lineSeparator()); + buffer.append(indent(6)).append("return when(method) {").append(System.lineSeparator()); + + for (int i = 0; i < getRoutes().size(); i++) { + buffer + .append(indent(8)) + .append("\"") + .append(fullMethods.get(i)) + .append("\" -> {") + .append(System.lineSeparator()); + getRoutes().get(i).generateJsonRpcDispatchCase(true).forEach(buffer::append); + buffer.append(indent(8)).append("}").append(System.lineSeparator()); + } + + buffer + .append(indent(8)) + .append( + "else -> throw" + + " io.jooby.rpc.jsonrpc.JsonRpcException(io.jooby.rpc.jsonrpc.JsonRpcErrorCode.METHOD_NOT_FOUND," + + " \"Method not found: $method\")") + .append(System.lineSeparator()); + buffer.append(indent(6)).append("}").append(System.lineSeparator()); + buffer.append(indent(4)).append("}").append(System.lineSeparator()); + + } else { + buffer.append(indent(4)).append("@Override").append(System.lineSeparator()); + buffer + .append(indent(4)) + .append("public void install(io.jooby.Jooby app) throws Exception {") + .append(System.lineSeparator()); + buffer + .append(indent(6)) + .append("app.getServices().listOf(io.jooby.rpc.jsonrpc.JsonRpcService.class).add(this);") + .append(System.lineSeparator()); + buffer + .append(indent(4)) + .append("}") + .append(System.lineSeparator()) + .append(System.lineSeparator()); + + buffer.append(indent(4)).append("@Override").append(System.lineSeparator()); + buffer + .append(indent(4)) + .append("public java.util.List getMethods() {") + .append(System.lineSeparator()); + buffer + .append(indent(6)) + .append("return java.util.List.of(") + .append(methodListString) + .append(");") + .append(System.lineSeparator()); + buffer + .append(indent(4)) + .append("}") + .append(System.lineSeparator()) + .append(System.lineSeparator()); + + buffer.append(indent(4)).append("@Override").append(System.lineSeparator()); + buffer + .append(indent(4)) + .append( + "public Object execute(io.jooby.Context ctx, io.jooby.rpc.jsonrpc.JsonRpcRequest req)" + + " throws Exception {") + .append(System.lineSeparator()); + buffer + .append(indent(6)) + .append(generateTypeName) + .append(" c = factory.apply(ctx);") + .append(System.lineSeparator()); + buffer + .append(indent(6)) + .append("String method = req.getMethod();") + .append(System.lineSeparator()); + buffer + .append(indent(6)) + .append( + "io.jooby.rpc.jsonrpc.JsonRpcParser parser =" + + " ctx.require(io.jooby.rpc.jsonrpc.JsonRpcParser.class);") + .append(System.lineSeparator()); + buffer.append(indent(6)).append("switch(method) {").append(System.lineSeparator()); + + for (int i = 0; i < getRoutes().size(); i++) { + buffer + .append(indent(8)) + .append("case \"") + .append(fullMethods.get(i)) + .append("\": {") + .append(System.lineSeparator()); + getRoutes().get(i).generateJsonRpcDispatchCase(false).forEach(buffer::append); + buffer.append(indent(8)).append("}").append(System.lineSeparator()); + } + + buffer.append(indent(8)).append("default:").append(System.lineSeparator()); + buffer + .append(indent(10)) + .append( + "throw new" + + " io.jooby.rpc.jsonrpc.JsonRpcException(io.jooby.rpc.jsonrpc.JsonRpcErrorCode.METHOD_NOT_FOUND," + + " \"Method not found: \" + method);") + .append(System.lineSeparator()); + buffer.append(indent(6)).append("}").append(System.lineSeparator()); + buffer.append(indent(4)).append("}").append(System.lineSeparator()); + } + + return template + .replace("${packageName}", getPackageName()) + .replace("${imports}", imports) + .replace("${className}", generateTypeName) + .replace("${generatedClassName}", generatedClass) + .replace("${implements}", "io.jooby.rpc.jsonrpc.JsonRpcService, io.jooby.Extension") + .replace("${constructors}", constructors(generatedClass, kt)) + .replace("${methods}", trimr(buffer)); + } +} diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java new file mode 100644 index 0000000000..636e183e8a --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java @@ -0,0 +1,682 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.apt; + +import static io.jooby.internal.apt.CodeBlock.*; + +import java.util.ArrayList; +import java.util.List; + +import javax.lang.model.element.ExecutableElement; + +public class McpRoute extends WebRoute { + private boolean isMcpTool = false; + private boolean isMcpPrompt = false; + private boolean isMcpResource = false; + private boolean isMcpResourceTemplate = false; + private boolean isMcpCompletion = false; + + public McpRoute(WebRouter router, ExecutableElement method) { + super(router, method); + checkMcpAnnotations(); + } + + private void checkMcpAnnotations() { + if (AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.McpTool") + != null) { + this.isMcpTool = true; + } + if (AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.McpPrompt") + != null) { + this.isMcpPrompt = true; + } + if (AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.McpCompletion") + != null) { + this.isMcpCompletion = true; + } + + var resourceAnno = + AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.McpResource"); + if (resourceAnno != null) { + String uri = + AnnotationSupport.findAnnotationValue(resourceAnno, "value"::equals).stream() + .findFirst() + .orElse(""); + if (uri.contains("{") && uri.contains("}")) { + this.isMcpResourceTemplate = true; + } else { + this.isMcpResource = true; + } + } + } + + public boolean isMcpTool() { + return isMcpTool; + } + + public boolean isMcpPrompt() { + return isMcpPrompt; + } + + public boolean isMcpResource() { + return isMcpResource; + } + + public boolean isMcpResourceTemplate() { + return isMcpResourceTemplate; + } + + public boolean isMcpCompletion() { + return isMcpCompletion; + } + + private String extractAnnotationValue(String annotationName, String attribute) { + var annotation = AnnotationSupport.findAnnotationByName(method, annotationName); + if (annotation == null) return ""; + return AnnotationSupport.findAnnotationValue(annotation, attribute::equals).stream() + .findFirst() + .orElse(""); + } + + public List generateMcpDefinitionMethod(boolean kt) { + List buffer = new ArrayList<>(); + + if (isMcpTool()) { + String toolName = extractAnnotationValue("io.jooby.annotation.McpTool", "name"); + if (toolName.isEmpty()) toolName = getMethodName(); + String description = extractAnnotationValue("io.jooby.annotation.McpTool", "description"); + + if (kt) { + buffer.add( + statement( + indent(4), + "private fun ", + getMethodName(), + "ToolSpec(mapper: tools.jackson.databind.ObjectMapper, schemaGenerator:" + + " com.github.victools.jsonschema.generator.SchemaGenerator):" + + " io.modelcontextprotocol.spec.McpSchema.Tool {")); + buffer.add(statement(indent(6), "val schema = mapper.createObjectNode()")); + buffer.add( + statement(indent(6), "schema.put(", string("type"), ", ", string("object"), ")")); + buffer.add( + statement(indent(6), "val props = schema.putObject(", string("properties"), ")")); + buffer.add(statement(indent(6), "val req = schema.putArray(", string("required"), ")")); + } else { + buffer.add( + statement( + indent(4), + "private io.modelcontextprotocol.spec.McpSchema.Tool ", + getMethodName(), + "ToolSpec(tools.jackson.databind.ObjectMapper mapper," + + " com.github.victools.jsonschema.generator.SchemaGenerator" + + " schemaGenerator) {")); + buffer.add(statement(indent(6), "var schema = mapper.createObjectNode()", semicolon(kt))); + buffer.add( + statement( + indent(6), + "schema.put(", + string("type"), + ", ", + string("object"), + ")", + semicolon(kt))); + buffer.add( + statement( + indent(6), + "var props = schema.putObject(", + string("properties"), + ")", + semicolon(kt))); + buffer.add( + statement( + indent(6), "var req = schema.putArray(", string("required"), ")", semicolon(kt))); + } + + for (MvcParameter param : getParameters(false)) { + String type = param.getType().getRawType().toString(); + if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange") + || type.equals("io.jooby.Context")) continue; + + String mcpName = param.getMcpName(); + + if (kt) { + buffer.add( + statement( + indent(6), + "props.set(", + string(mcpName), + ", schemaGenerator.generateSchema(", + type, + "::class.java))")); + if (!param.isNullable(kt)) + buffer.add(statement(indent(6), "req.add(", string(mcpName), ")")); + } else { + buffer.add( + statement( + indent(6), + "props.set(", + string(mcpName), + ", schemaGenerator.generateSchema(", + type, + ".class))", + semicolon(kt))); + if (!param.isNullable(kt)) + buffer.add(statement(indent(6), "req.add(", string(mcpName), ")", semicolon(kt))); + } + } + + String returnTypeStr = getReturnType().getRawType().toString(); + boolean isPrimitive = + returnTypeStr.equals("int") + || returnTypeStr.equals("long") + || returnTypeStr.equals("double") + || returnTypeStr.equals("float") + || returnTypeStr.equals("boolean") + || returnTypeStr.equals("byte") + || returnTypeStr.equals("short") + || returnTypeStr.equals("char"); + boolean isLangClass = returnTypeStr.startsWith("java.lang."); + boolean isMcpClass = returnTypeStr.startsWith("io.modelcontextprotocol.spec.McpSchema"); + + boolean generateOutputSchema = + !returnType.isVoid() + && !getReturnType().is("io.jooby.StatusCode") + && !isPrimitive + && !isLangClass + && !isMcpClass; + String outputSchemaArg = "null"; + + if (generateOutputSchema) { + outputSchemaArg = getMethodName() + "OutputSchema"; + if (kt) { + buffer.add( + statement( + indent(6), + "val ", + outputSchemaArg, + "Node = schemaGenerator.generateSchema(", + returnTypeStr, + "::class.java)")); + buffer.add( + statement( + indent(6), + "val ", + outputSchemaArg, + " = mapper.convertValue(", + outputSchemaArg, + "Node, Map::class.java) as Map")); + } else { + buffer.add( + statement( + indent(6), + "var ", + outputSchemaArg, + "Node = schemaGenerator.generateSchema(", + returnTypeStr, + ".class)", + semicolon(kt))); + buffer.add( + statement( + indent(6), + "var ", + outputSchemaArg, + " = mapper.convertValue(", + outputSchemaArg, + "Node, java.util.Map.class)", + semicolon(kt))); + } + } + + if (kt) { + buffer.add( + statement( + indent(6), + "return io.modelcontextprotocol.spec.McpSchema.Tool(", + string(toolName), + ", null, ", + string(description), + ", mapper.treeToValue(schema," + + " io.modelcontextprotocol.spec.McpSchema.JsonSchema::class.java), ", + outputSchemaArg, + ", null, null)")); + } else { + buffer.add( + statement( + indent(6), + "return new io.modelcontextprotocol.spec.McpSchema.Tool(", + string(toolName), + ", null, ", + string(description), + ", mapper.treeToValue(schema," + + " io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), ", + outputSchemaArg, + ", null, null)", + semicolon(kt))); + } + buffer.add(statement(indent(4), "}\n")); + + } else if (isMcpPrompt()) { + String promptName = extractAnnotationValue("io.jooby.annotation.McpPrompt", "name"); + if (promptName.isEmpty()) promptName = getMethodName(); + String description = extractAnnotationValue("io.jooby.annotation.McpPrompt", "description"); + + if (kt) { + buffer.add( + statement( + indent(4), + "private fun ", + getMethodName(), + "PromptSpec(): io.modelcontextprotocol.spec.McpSchema.Prompt {")); + buffer.add( + statement( + indent(6), + "val args =" + + " mutableListOf()")); + } else { + buffer.add( + statement( + indent(4), + "private io.modelcontextprotocol.spec.McpSchema.Prompt ", + getMethodName(), + "PromptSpec() {")); + buffer.add( + statement( + indent(6), + "var args = new" + + " java.util.ArrayList()", + semicolon(kt))); + } + + for (MvcParameter param : getParameters(false)) { + String type = param.getType().getRawType().toString(); + if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange") + || type.equals("io.jooby.Context")) continue; + + String mcpName = param.getMcpName(); + boolean isRequired = !param.isNullable(kt); + + if (kt) { + buffer.add( + statement( + indent(6), + "args.add(io.modelcontextprotocol.spec.McpSchema.PromptArgument(", + string(mcpName), + ", null, ", + String.valueOf(isRequired), + "))")); + } else { + buffer.add( + statement( + indent(6), + "args.add(new io.modelcontextprotocol.spec.McpSchema.PromptArgument(", + string(mcpName), + ", null, ", + String.valueOf(isRequired), + "))", + semicolon(kt))); + } + } + + if (kt) { + buffer.add( + statement( + indent(6), + "return io.modelcontextprotocol.spec.McpSchema.Prompt(", + string(promptName), + ", null, ", + string(description), + ", args)")); + } else { + buffer.add( + statement( + indent(6), + "return new io.modelcontextprotocol.spec.McpSchema.Prompt(", + string(promptName), + ", null, ", + string(description), + ", args)", + semicolon(kt))); + } + buffer.add(statement(indent(4), "}\n")); + + } else if (isMcpResource() || isMcpResourceTemplate()) { + String uri = extractAnnotationValue("io.jooby.annotation.McpResource", "value"); + String name = extractAnnotationValue("io.jooby.annotation.McpResource", "name"); + if (name.isEmpty()) name = getMethodName(); + String description = extractAnnotationValue("io.jooby.annotation.McpResource", "description"); + + boolean isTemplate = isMcpResourceTemplate(); + String specType = isTemplate ? "ResourceTemplate" : "Resource"; + + if (kt) { + buffer.add( + statement( + indent(4), + "private fun ", + getMethodName(), + specType, + "Spec(): io.modelcontextprotocol.spec.McpSchema.", + specType, + " {")); + if (!isTemplate) { + buffer.add( + statement( + indent(6), + "return io.modelcontextprotocol.spec.McpSchema.Resource(", + string(uri), + ", ", + string(name), + ", null, ", + string(description), + ", null, null, null, null)")); + } else { + buffer.add( + statement( + indent(6), + "return io.modelcontextprotocol.spec.McpSchema.ResourceTemplate(", + string(uri), + ", ", + string(name), + ", null, ", + string(description), + ", null, null, null)")); + } + buffer.add(statement(indent(4), "}\n")); + } else { + buffer.add( + statement( + indent(4), + "private io.modelcontextprotocol.spec.McpSchema.", + specType, + " ", + getMethodName(), + specType, + "Spec() {")); + if (!isTemplate) { + buffer.add( + statement( + indent(6), + "return new io.modelcontextprotocol.spec.McpSchema.Resource(", + string(uri), + ", ", + string(name), + ", null, ", + string(description), + ", null, null, null, null)", + semicolon(kt))); + } else { + buffer.add( + statement( + indent(6), + "return new io.modelcontextprotocol.spec.McpSchema.ResourceTemplate(", + string(uri), + ", ", + string(name), + ", null, ", + string(description), + ", null, null, null)", + semicolon(kt))); + } + buffer.add(statement(indent(4), "}\n")); + } + } + return buffer; + } + + public List generateMcpHandlerMethod(boolean kt) { + List buffer = new ArrayList<>(); + + String reqType = ""; + String resType = ""; + String toMethod = ""; + + if (isMcpTool()) { + reqType = "CallToolRequest"; + resType = "CallToolResult"; + toMethod = "toCallToolResult"; + } else if (isMcpPrompt()) { + reqType = "GetPromptRequest"; + resType = "GetPromptResult"; + toMethod = "toPromptResult"; + } else if (isMcpResource() || isMcpResourceTemplate()) { + reqType = "ReadResourceRequest"; + resType = "ReadResourceResult"; + toMethod = "toResourceResult"; + } else { + return buffer; + } + + if (kt) { + buffer.add( + statement( + indent(4), + "private fun ", + getMethodName(), + "(exchange: io.modelcontextprotocol.server.McpSyncServerExchange, req:" + + " io.modelcontextprotocol.spec.McpSchema.", + reqType, + "): io.modelcontextprotocol.spec.McpSchema.", + resType, + " {")); + buffer.add( + statement( + indent(6), + "val ctx =" + + " exchange.transportContext().get(io.jooby.Context::class.java.name)")); + } else { + buffer.add( + statement( + indent(4), + "private io.modelcontextprotocol.spec.McpSchema.", + resType, + " ", + getMethodName(), + "(io.modelcontextprotocol.server.McpSyncServerExchange exchange," + + " io.modelcontextprotocol.spec.McpSchema.", + reqType, + " req) {")); + buffer.add( + statement( + indent(6), + "var ctx = (io.jooby.Context) exchange.transportContext().get(\"CTX\")", + semicolon(kt))); + } + + if (isMcpTool() || isMcpPrompt()) { + if (kt) { + buffer.add(statement(indent(6), "val args = req.arguments() ?: emptyMap()")); + } else { + buffer.add( + statement( + indent(6), + "var args = req.arguments() != null ? req.arguments() :" + + " java.util.Collections.emptyMap()", + semicolon(kt))); + } + } else if (isMcpResource() || isMcpResourceTemplate()) { + String uriTemplate = extractAnnotationValue("io.jooby.annotation.McpResource", "value"); + boolean isTemplate = isMcpResourceTemplate(); + + if (isTemplate) { + if (kt) { + buffer.add(statement(indent(6), "val uri = req.uri()")); + buffer.add( + statement( + indent(6), + "val manager = io.modelcontextprotocol.util.DefaultMcpUriTemplateManager(", + string(uriTemplate), + ")")); + buffer.add(statement(indent(6), "val args = mutableMapOf()")); + buffer.add(statement(indent(6), "args.putAll(manager.extractVariableValues(uri))")); + } else { + buffer.add(statement(indent(6), "var uri = req.uri()", semicolon(kt))); + buffer.add( + statement( + indent(6), + "var manager = new io.modelcontextprotocol.util.DefaultMcpUriTemplateManager(", + string(uriTemplate), + ")", + semicolon(kt))); + buffer.add( + statement( + indent(6), "var args = new java.util.HashMap()", semicolon(kt))); + buffer.add( + statement( + indent(6), "args.putAll(manager.extractVariableValues(uri))", semicolon(kt))); + } + } else { + if (kt) { + buffer.add(statement(indent(6), "val args = emptyMap()")); + } else { + buffer.add( + statement( + indent(6), + "var args = java.util.Collections.emptyMap()", + semicolon(kt))); + } + } + } + + buffer.add( + statement(indent(6), kt ? "val " : "var ", "c = this.factory.apply(ctx)", semicolon(kt))); + + List javaParamNames = new ArrayList<>(); + for (MvcParameter param : getParameters(false)) { + String javaName = param.getName(); + String mcpName = param.getMcpName(); + String type = param.getType().getRawType().toString(); + boolean isNullable = param.isNullable(kt); + javaParamNames.add(javaName); + + if (type.equals("io.jooby.Context")) { + buffer.add(statement(indent(6), kt ? "val " : "var ", javaName, " = ctx", semicolon(kt))); + continue; + } else if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange")) { + buffer.add( + statement(indent(6), kt ? "val " : "var ", javaName, " = exchange", semicolon(kt))); + continue; + } else if (type.equals("io.modelcontextprotocol.spec.McpSchema." + reqType)) { + buffer.add(statement(indent(6), kt ? "val " : "var ", javaName, " = req", semicolon(kt))); + continue; + } + + if (kt) { + buffer.add( + statement(indent(6), "val raw_", javaName, " = args.get(", string(mcpName), ")")); + if (!isNullable) + buffer.add( + statement( + indent(6), + "if (raw_", + javaName, + " == null) throw IllegalArgumentException(", + string("Missing req param: " + mcpName), + ")")); + buffer.add( + statement( + indent(6), + "val ", + javaName, + " = raw_", + javaName, + " as ", + type, + isNullable ? "?" : "")); + } else { + buffer.add( + statement( + indent(6), + "var raw_", + javaName, + " = args.get(", + string(mcpName), + ")", + semicolon(kt))); + if (!isNullable) + buffer.add( + statement( + indent(6), + "if (raw_", + javaName, + " == null) throw new IllegalArgumentException(", + string("Missing req param: " + mcpName), + ")", + semicolon(kt))); + + if (type.equals("int") || type.equals("java.lang.Integer")) { + buffer.add( + statement( + indent(6), + "var ", + javaName, + " = ", + isNullable ? "(raw_" + javaName + " == null) ? null : " : "", + "raw_", + javaName, + " instanceof Number ? ((Number) raw_", + javaName, + ").intValue() : Integer.parseInt(raw_", + javaName, + ".toString())", + semicolon(kt))); + } else if (type.equals("java.lang.String")) { + buffer.add( + statement( + indent(6), + "var ", + javaName, + " = raw_", + javaName, + " != null ? raw_", + javaName, + ".toString() : null", + semicolon(kt))); + } else { + buffer.add( + statement( + indent(6), "var ", javaName, " = (", type, ") raw_", javaName, semicolon(kt))); + } + } + } + + String methodCall = "c." + getMethodName() + "(" + String.join(", ", javaParamNames) + ")"; + + if (getReturnType().isVoid()) { + buffer.add(statement(indent(6), methodCall, semicolon(kt))); + if (kt) { + buffer.add( + statement(indent(6), "return io.jooby.mcp.McpResult(this.json).", toMethod, "(null)")); + } else { + buffer.add( + statement( + indent(6), + "return new io.jooby.mcp.McpResult(this.json).", + toMethod, + "(null)", + semicolon(kt))); + } + } else { + if (kt) { + buffer.add(statement(indent(6), "val result = ", methodCall)); + buffer.add( + statement( + indent(6), "return io.jooby.mcp.McpResult(this.json).", toMethod, "(result)")); + } else { + buffer.add(statement(indent(6), "var result = ", methodCall, semicolon(kt))); + buffer.add( + statement( + indent(6), + "return new io.jooby.mcp.McpResult(this.json).", + toMethod, + "(result)", + semicolon(kt))); + } + } + buffer.add(statement(indent(4), "}\n")); + + return buffer; + } +} diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java new file mode 100644 index 0000000000..e9d30bcb2c --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java @@ -0,0 +1,545 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.apt; + +import static io.jooby.internal.apt.AnnotationSupport.VALUE; +import static io.jooby.internal.apt.CodeBlock.*; + +import java.io.IOException; + +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.TypeElement; + +public class McpRouter extends WebRouter { + + public McpRouter(MvcContext context, TypeElement clazz) { + super(context, clazz); + } + + public static McpRouter parse(MvcContext context, TypeElement controller) { + McpRouter router = new McpRouter(context, controller); + for (var enclosed : controller.getEnclosedElements()) { + if (enclosed.getKind() == ElementKind.METHOD) { + McpRoute route = new McpRoute(router, (ExecutableElement) enclosed); + if (route.isMcpTool() + || route.isMcpPrompt() + || route.isMcpResource() + || route.isMcpResourceTemplate() + || route.isMcpCompletion()) { + router.routes.put(route.getMethodName(), route); + } + } + } + return router; + } + + @Override + public String getGeneratedType() { + return context.generateRouterName(getTargetType().getQualifiedName().toString() + "Mcp"); + } + + public String getMcpServerKey() { + var annotation = AnnotationSupport.findAnnotationByName(clazz, "io.jooby.annotation.McpServer"); + if (annotation != null) { + return AnnotationSupport.findAnnotationValue(annotation, VALUE).stream() + .findFirst() + .orElse("default"); + } + return "default"; + } + + private String findTargetMethodName(String ref) { + for (McpRoute route : getRoutes()) { + if (route.isMcpPrompt()) { + var annotation = + AnnotationSupport.findAnnotationByName( + route.getMethod(), "io.jooby.annotation.McpPrompt"); + String name = + annotation != null + ? AnnotationSupport.findAnnotationValue(annotation, "name"::equals).stream() + .findFirst() + .orElse("") + : ""; + if (name.isEmpty()) name = route.getMethodName(); + if (ref.equals(name)) return route.getMethodName(); + } else if (route.isMcpResource() || route.isMcpResourceTemplate()) { + var annotation = + AnnotationSupport.findAnnotationByName( + route.getMethod(), "io.jooby.annotation.McpResource"); + String uri = + annotation != null + ? AnnotationSupport.findAnnotationValue(annotation, "value"::equals).stream() + .findFirst() + .orElse("") + : ""; + if (ref.equals(uri)) return route.getMethodName(); + } + } + return "mcpTarget" + Math.abs(ref.hashCode()); + } + + @Override + public String getSourceCode(Boolean generateKotlin) throws IOException { + if (isEmpty()) return null; + + boolean kt = generateKotlin == Boolean.TRUE || isKt(); + var generateTypeName = getTargetType().getSimpleName().toString(); + var mcpClassName = getGeneratedType().substring(getGeneratedType().lastIndexOf('.') + 1); + var packageName = getPackageName(); + + var template = kt ? KOTLIN : JAVA; + var buffer = new StringBuilder(); + + context.generateStaticImports( + this, + (owner, fn) -> + buffer.append( + statement("import ", kt ? "" : "static ", owner, ".", fn, semicolon(kt)))); + var imports = buffer.toString(); + buffer.setLength(0); + + var tools = getRoutes().stream().filter(McpRoute::isMcpTool).toList(); + var prompts = getRoutes().stream().filter(McpRoute::isMcpPrompt).toList(); + var resources = + getRoutes().stream().filter(r -> r.isMcpResource() || r.isMcpResourceTemplate()).toList(); + + var completionRoutes = getRoutes().stream().filter(McpRoute::isMcpCompletion).toList(); + java.util.Map> completionGroups = + new java.util.LinkedHashMap<>(); + for (McpRoute route : completionRoutes) { + var annotation = + AnnotationSupport.findAnnotationByName( + route.getMethod(), "io.jooby.annotation.McpCompletion"); + String ref = + AnnotationSupport.findAnnotationValue(annotation, "value"::equals).stream() + .findFirst() + .orElse(""); + if (ref == null || ref.isEmpty()) { + ref = + AnnotationSupport.findAnnotationValue(annotation, "ref"::equals).stream() + .findFirst() + .orElse(""); + } + completionGroups.computeIfAbsent(ref, k -> new java.util.ArrayList<>()).add(route); + } + + if (kt) { + buffer.append( + statement( + indent(4), + "private lateinit var json: io.modelcontextprotocol.json.McpJsonMapper\n")); + } else { + buffer.append( + statement( + indent(4), + "private io.modelcontextprotocol.json.McpJsonMapper json", + semicolon(kt), + "\n")); + } + + if (kt) { + buffer.append( + statement( + indent(4), + "override fun capabilities(capabilities:" + + " io.modelcontextprotocol.spec.McpSchema.ServerCapabilities.Builder) {")); + } else { + buffer.append(statement(indent(4), "@Override")); + buffer.append( + statement( + indent(4), + "public void" + + " capabilities(io.modelcontextprotocol.spec.McpSchema.ServerCapabilities.Builder" + + " capabilities) {")); + } + if (!tools.isEmpty()) + buffer.append(statement(indent(6), "capabilities.tools(true)", semicolon(kt))); + if (!prompts.isEmpty()) + buffer.append(statement(indent(6), "capabilities.prompts(true)", semicolon(kt))); + if (!resources.isEmpty()) + buffer.append(statement(indent(6), "capabilities.resources(true, true)", semicolon(kt))); + buffer.append(statement(indent(4), "}\n")); + + String serverName = getMcpServerKey(); + if (kt) { + buffer.append(statement(indent(4), "override fun serverName(): String? {")); + buffer.append(statement(indent(6), "return ", string(serverName))); + } else { + buffer.append(statement(indent(4), "@Override")); + buffer.append(statement(indent(4), "public String serverName() {")); + buffer.append(statement(indent(6), "return ", string(serverName), semicolon(kt))); + } + buffer.append(statement(indent(4), "}\n")); + + if (kt) { + buffer.append( + statement( + indent(4), + "override fun completions():" + + " List" + + " {")); + buffer.append( + statement( + indent(6), + "val completions =" + + " mutableListOf()")); + } else { + buffer.append(statement(indent(4), "@Override")); + buffer.append( + statement( + indent(4), + "public" + + " java.util.List" + + " completions() {")); + buffer.append( + statement( + indent(6), + "var completions = new" + + " java.util.ArrayList()", + semicolon(kt))); + } + + for (String ref : completionGroups.keySet()) { + boolean isResource = ref.contains("://"); + String handlerName = findTargetMethodName(ref) + "CompletionHandler"; + String refObj = + isResource + ? "io.modelcontextprotocol.spec.McpSchema.ResourceReference" + : "io.modelcontextprotocol.spec.McpSchema.PromptReference"; + + if (kt) { + buffer.append( + statement( + indent(6), + "completions.add(io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(", + refObj, + "(", + string(ref), + "), this::", + handlerName, + "))")); + } else { + buffer.append( + statement( + indent(6), + "completions.add(new" + + " io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new" + + " ", + refObj, + "(", + string(ref), + "), this::", + handlerName, + "))", + semicolon(kt))); + } + } + buffer.append(statement(indent(6), "return completions", semicolon(kt))); + buffer.append(statement(indent(4), "}\n")); + + if (kt) { + buffer.append(statement(indent(4), "@Throws(Exception::class)")); + buffer.append( + statement( + indent(4), + "override fun install(app: io.jooby.Jooby, server:" + + " io.modelcontextprotocol.server.McpSyncServer, json:" + + " io.modelcontextprotocol.json.McpJsonMapper) {")); + buffer.append(statement(indent(6), "this.json = json")); + buffer.append( + statement( + indent(6), + "val mapper = app.require(tools.jackson.databind.ObjectMapper::class.java)")); + if (!tools.isEmpty()) { + buffer.append( + statement( + indent(6), + "val configBuilder =" + + " com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder(com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12," + + " com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON)")); + buffer.append( + statement( + indent(6), + "val schemaGenerator =" + + " com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build())\n")); + } + } else { + buffer.append(statement(indent(4), "@Override")); + buffer.append( + statement( + indent(4), + "public void install(io.jooby.Jooby app, io.modelcontextprotocol.server.McpSyncServer" + + " server, io.modelcontextprotocol.json.McpJsonMapper json) throws Exception" + + " {")); + buffer.append(statement(indent(6), "this.json = json", semicolon(kt))); + buffer.append( + statement( + indent(6), + "var mapper = app.require(tools.jackson.databind.ObjectMapper.class)", + semicolon(kt))); + if (!tools.isEmpty()) { + buffer.append( + statement( + indent(6), + "var configBuilder = new" + + " com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder(com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12," + + " com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON)", + semicolon(kt))); + buffer.append( + statement( + indent(6), + "var schemaGenerator = new" + + " com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build())", + semicolon(kt), + "\n")); + } + } + + for (var route : + getRoutes().stream() + .filter( + r -> + r.isMcpTool() + || r.isMcpPrompt() + || r.isMcpResource() + || r.isMcpResourceTemplate()) + .toList()) { + String methodName = route.getMethodName(); + + if (route.isMcpTool()) { + String defArgs = "mapper, schemaGenerator"; + if (kt) { + buffer.append( + statement( + indent(6), + "server.addTool(io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification(", + methodName, + "ToolSpec(", + defArgs, + "), this::", + methodName, + "))\n")); + } else { + buffer.append( + statement( + indent(6), + "server.addTool(new" + + " io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification(", + methodName, + "ToolSpec(", + defArgs, + "), this::", + methodName, + "))", + semicolon(kt), + "\n")); + } + } else if (route.isMcpPrompt()) { + if (kt) { + buffer.append( + statement( + indent(6), + "server.addPrompt(io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification(", + methodName, + "PromptSpec(), this::", + methodName, + "))\n")); + } else { + buffer.append( + statement( + indent(6), + "server.addPrompt(new" + + " io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification(", + methodName, + "PromptSpec(), this::", + methodName, + "))", + semicolon(kt), + "\n")); + } + } else if (route.isMcpResource() || route.isMcpResourceTemplate()) { + boolean isTemplate = route.isMcpResourceTemplate(); + String specType = + isTemplate ? "SyncResourceTemplateSpecification" : "SyncResourceSpecification"; + String addMethod = isTemplate ? "server.addResourceTemplate(" : "server.addResource("; + String defMethod = isTemplate ? "ResourceTemplateSpec()" : "ResourceSpec()"; + + if (kt) { + buffer.append( + statement( + indent(6), + addMethod, + "io.modelcontextprotocol.server.McpServerFeatures.", + specType, + "(", + methodName, + defMethod, + ", this::", + methodName, + "))\n")); + } else { + buffer.append( + statement( + indent(6), + addMethod, + "new io.modelcontextprotocol.server.McpServerFeatures.", + specType, + "(", + methodName, + defMethod, + ", this::", + methodName, + "))", + semicolon(kt), + "\n")); + } + } + } + buffer.append(statement(indent(4), "}\n")); + + for (McpRoute route : + getRoutes().stream() + .filter( + r -> + r.isMcpTool() + || r.isMcpPrompt() + || r.isMcpResource() + || r.isMcpResourceTemplate()) + .toList()) { + route.generateMcpDefinitionMethod(kt).forEach(buffer::append); + route.generateMcpHandlerMethod(kt).forEach(buffer::append); + } + + for (var entry : completionGroups.entrySet()) { + String ref = entry.getKey(); + String handlerName = findTargetMethodName(ref) + "CompletionHandler"; + java.util.List routes = entry.getValue(); + + if (kt) { + buffer.append( + statement( + indent(4), + "private fun ", + handlerName, + "(exchange: io.modelcontextprotocol.server.McpSyncServerExchange, req:" + + " io.modelcontextprotocol.spec.McpSchema.CompleteRequest):" + + " io.modelcontextprotocol.spec.McpSchema.CompleteResult {")); + buffer.append( + statement( + indent(6), + "val ctx =" + + " exchange.transportContext().get(io.jooby.Context::class.java.name)")); + buffer.append(statement(indent(6), "val c = this.factory.apply(ctx)")); + buffer.append(statement(indent(6), "val targetArg = req.argument()?.name() ?: \"\"")); + buffer.append(statement(indent(6), "val typedValue = req.argument()?.value() ?: \"\"")); + buffer.append(statement(indent(6), "return when (targetArg) {")); + } else { + buffer.append( + statement( + indent(4), + "private io.modelcontextprotocol.spec.McpSchema.CompleteResult ", + handlerName, + "(io.modelcontextprotocol.server.McpSyncServerExchange exchange," + + " io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) {")); + buffer.append( + statement( + indent(6), + "var ctx = (io.jooby.Context) exchange.transportContext().get(\"CTX\")", + semicolon(kt))); + buffer.append(statement(indent(6), "var c = this.factory.apply(ctx)", semicolon(kt))); + buffer.append( + statement( + indent(6), + "var targetArg = req.argument() != null ? req.argument().name() : \"\"", + semicolon(kt))); + buffer.append( + statement( + indent(6), + "var typedValue = req.argument() != null ? req.argument().value() : \"\"", + semicolon(kt))); + buffer.append(statement(indent(6), "return switch (targetArg) {")); + } + + for (McpRoute route : routes) { + String targetArgName = null; + java.util.List invokeArgs = new java.util.ArrayList<>(); + + for (var param : route.getParameters(false)) { + String type = param.getType().getRawType().toString(); + if (type.equals("io.jooby.Context")) { + invokeArgs.add("ctx"); + } else if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange")) { + invokeArgs.add("exchange"); + } else { + targetArgName = param.getMcpName(); + invokeArgs.add("typedValue"); + } + } + + if (targetArgName == null) continue; + + if (kt) { + buffer.append(statement(indent(8), string(targetArgName), " -> {")); + buffer.append( + statement( + indent(10), + "val result = c.", + route.getMethodName(), + "(", + String.join(", ", invokeArgs), + ")")); + buffer.append( + statement(indent(10), "io.jooby.mcp.McpResult(this.json).toCompleteResult(result)")); + buffer.append(statement(indent(8), "}")); + } else { + buffer.append(statement(indent(8), "case ", string(targetArgName), " -> {")); + buffer.append( + statement( + indent(10), + "var result = c.", + route.getMethodName(), + "(", + String.join(", ", invokeArgs), + ")", + semicolon(kt))); + buffer.append( + statement( + indent(10), + "yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result)", + semicolon(kt))); + buffer.append(statement(indent(8), "}")); + } + } + + if (kt) { + buffer.append( + statement( + indent(8), + "else -> io.jooby.mcp.McpResult(this.json).toCompleteResult(emptyList())")); + buffer.append(statement(indent(6), "}")); + } else { + buffer.append( + statement( + indent(8), + "default -> new" + + " io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of())", + semicolon(kt))); + buffer.append(statement(indent(6), "}", semicolon(kt))); + } + buffer.append(statement(indent(4), "}\n")); + } + + return template + .replace("${packageName}", packageName) + .replace("${imports}", imports) + .replace("${className}", generateTypeName) + .replace("${generatedClassName}", mcpClassName) + .replace("${implements}", "io.jooby.mcp.McpService") + .replace("${constructors}", constructors(mcpClassName, kt)) + .replace("${methods}", trimr(buffer)); + } +} diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcContext.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcContext.java index 3a19fd61f9..99e42e9301 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcContext.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcContext.java @@ -28,7 +28,7 @@ public class MvcContext { private final String routerPrefix; private final String routerSuffix; private final BiConsumer output; - private final List routers = new ArrayList<>(); + private final List> routers = new ArrayList<>(); private final boolean mvcMethod; private final Map reactiveTypeMap = new HashMap<>(); @@ -67,11 +67,11 @@ private void computeReactiveTypes( }); } - public void add(MvcRouter router) { + public void add(WebRouter router) { routers.add(router); } - public List getRouters() { + public List> getRouters() { return routers; } @@ -202,10 +202,11 @@ private void report(Diagnostic.Kind kind, String message, Object... args) { output.accept(kind, msg); } - public void generateStaticImports(MvcRouter mvcRouter, BiConsumer consumer) { - List routes = mvcRouter.getRoutes(); + public void generateStaticImports( + WebRouter mvcRouter, BiConsumer consumer) { + var routes = mvcRouter.getRoutes(); var process = new HashSet(); - for (MvcRoute route : routes) { + for (var route : routes) { var returnType = route.getReturnTypeHandler(); if (process.add(returnType.toString())) { var fnq = findMappingHandler(returnType); diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java index 7c68b84c57..684b4fcb73 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java @@ -18,13 +18,13 @@ import javax.lang.model.element.VariableElement; public class MvcParameter { - private final MvcRoute route; + private final WebRoute route; private final VariableElement parameter; private final Map annotations; private final TypeDefinition type; private final boolean requireBeanValidation; - public MvcParameter(MvcContext context, MvcRoute route, VariableElement parameter) { + public MvcParameter(MvcContext context, WebRoute route, VariableElement parameter) { this.route = route; this.parameter = parameter; this.annotations = annotationMap(parameter); diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java index 21d0da5383..a94ff25003 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java @@ -48,7 +48,7 @@ public MvcRoute(MvcContext context, MvcRouter router, ExecutableElement method) this.router = router; this.method = method; this.parameters = - method.getParameters().stream().map(it -> new MvcParameter(context, this, it)).toList(); + method.getParameters().stream().map(it -> new MvcParameter(context, null, it)).toList(); this.hasBeanValidation = parameters.stream().anyMatch(MvcParameter::isRequireBeanValidation); this.suspendFun = !parameters.isEmpty() @@ -64,7 +64,7 @@ public MvcRoute(MvcContext context, MvcRouter router, MvcRoute route) { this.router = router; this.method = route.method; this.parameters = - method.getParameters().stream().map(it -> new MvcParameter(context, this, it)).toList(); + method.getParameters().stream().map(it -> new MvcParameter(context, null, it)).toList(); this.hasBeanValidation = parameters.stream().anyMatch(MvcParameter::isRequireBeanValidation); this.returnType = new TypeDefinition( @@ -272,7 +272,7 @@ public List generateMapping(boolean kt) { dispatch -> block.add(statement(indent(2), ".setExecutorKey(", string(dispatch), ")"))); attributeGenerator - .toSourceCode(kt, this, 2) + .toSourceCode(kt, null, 2) .ifPresent( attributes -> block.add(statement(indent(2), ".setAttributes(", attributes, ")"))); var lineSep = lastLine ? lineSeparator() : lineSeparator() + lineSeparator(); diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java index 1d4dca556d..bf6384b230 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java @@ -270,7 +270,7 @@ public String getRestSourceCode(Boolean generateKotlin) throws IOException { var noSuspended = mvcRoutes.stream().filter(it -> !it.isSuspendFun()).toList(); var buffer = new StringBuilder(); context.generateStaticImports( - this, + null, (owner, fn) -> buffer.append( statement("import ", kt ? "" : "static ", owner, ".", fn, semicolon(kt)))); @@ -340,7 +340,7 @@ public String getRpcSourceCode(Boolean generateKotlin) { var buffer = new StringBuilder(); context.generateStaticImports( - this, + null, (owner, fn) -> buffer.append( statement("import ", kt ? "" : "static ", owner, ".", fn, semicolon(kt)))); @@ -549,7 +549,7 @@ public String getMcpSourceCode(Boolean generateKotlin) { var buffer = new StringBuilder(); context.generateStaticImports( - this, + null, (owner, fn) -> buffer.append( statement("import ", kt ? "" : "static ", owner, ".", fn, semicolon(kt)))); diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ParameterGenerator.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ParameterGenerator.java index 599bf7a6db..9dd276a08d 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ParameterGenerator.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ParameterGenerator.java @@ -20,7 +20,7 @@ public enum ParameterGenerator { @Override public String toSourceCode( boolean kt, - MvcRoute route, + WebRoute route, AnnotationMirror annotation, TypeDefinition type, VariableElement parameter, @@ -68,7 +68,7 @@ public String parameterName(AnnotationMirror annotation, String defaultParameter @Override public String toSourceCode( boolean kt, - MvcRoute route, + WebRoute route, AnnotationMirror annotation, TypeDefinition type, VariableElement parameter, @@ -106,7 +106,7 @@ public String parameterName(AnnotationMirror annotation, String defaultParameter @Override public String toSourceCode( boolean kt, - MvcRoute route, + WebRoute route, AnnotationMirror annotation, TypeDefinition type, VariableElement parameter, @@ -200,7 +200,7 @@ protected Predicate namePredicate(AnnotationMirror annotation) { public String toSourceCode( boolean kt, - MvcRoute route, + WebRoute route, AnnotationMirror annotation, TypeDefinition type, VariableElement parameter, @@ -402,7 +402,7 @@ public static ParameterGenerator findByAnnotation(String annotation) { this.typeRestrictions = typeRestrictions; } - public void verifyType(String type, String parameterName, MvcRoute route) { + public void verifyType(String type, String parameterName, WebRoute route) { if (!typeRestrictions.isEmpty()) { if (typeRestrictions.stream().noneMatch(type::equals)) { throw new IllegalArgumentException( diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java new file mode 100644 index 0000000000..e3b68760bf --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java @@ -0,0 +1,383 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.apt; + +import static io.jooby.internal.apt.AnnotationSupport.*; +import static io.jooby.internal.apt.CodeBlock.*; +import static java.lang.System.lineSeparator; +import static java.util.Optional.ofNullable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.StringJoiner; +import java.util.function.Function; +import java.util.stream.Collectors; + +import javax.lang.model.element.Element; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.TypeElement; +import javax.lang.model.type.TypeKind; + +public class RestRoute extends WebRoute { + private final TypeElement httpMethodAnnotation; + private String generatedName; + + public RestRoute( + WebRouter router, ExecutableElement method, TypeElement httpMethodAnnotation) { + super(router, method); + this.httpMethodAnnotation = httpMethodAnnotation; + this.generatedName = method.getSimpleName().toString(); + } + + public TypeElement getHttpMethodAnnotation() { + return httpMethodAnnotation; + } + + public String getGeneratedName() { + return generatedName; + } + + public void setGeneratedName(String generatedName) { + this.generatedName = generatedName; + } + + static String leadingSlash(String path) { + if (path == null || path.isEmpty() || path.equals("/")) { + return "/"; + } + return path.charAt(0) == '/' ? path : "/" + path; + } + + private String methodReference(boolean kt, String thisRef, String methodName) { + if (kt) { + var generics = returnType.getArgumentsString(kt, true, Set.of(TypeKind.TYPEVAR)); + if (!generics.isEmpty()) { + return CodeBlock.of(") { ctx -> ", methodName, generics, "(ctx) }"); + } + } + return thisRef + methodName + ")"; + } + + private Optional dispatch() { + var dispatch = dispatch(method); + return dispatch.isEmpty() ? dispatch(method.getEnclosingElement()) : dispatch; + } + + private Optional dispatch(Element element) { + return ofNullable(findAnnotationByName(element, "io.jooby.annotation.Dispatch")) + .map( + it -> + findAnnotationValue(it, AnnotationSupport.VALUE).stream() + .findFirst() + .orElse("worker")); + } + + private Optional mediaType(Function> lookup) { + var scopes = List.of(method, method.getEnclosingElement()); + var i = 0; + var types = Collections.emptyList(); + while (types.isEmpty() && i < scopes.size()) { + types = lookup.apply(scopes.get(i++)); + } + if (types.isEmpty()) { + return Optional.empty(); + } + return Optional.of( + types.stream() + .map(type -> CodeBlock.of("io.jooby.MediaType.valueOf(", string(type), ")")) + .collect(Collectors.joining(", ", "java.util.List.of(", ")"))); + } + + private String javadocComment(boolean kt, String routerName) { + if (kt) { + return CodeBlock.statement("/** See [", routerName, ".", getMethodName(), "]", " */"); + } + return CodeBlock.statement( + "/** See {@link ", + routerName, + "#", + getMethodName(), + "(", + String.join(", ", getRawParameterTypes(true, false)), + ") */"); + } + + public List generateMapping(boolean kt, String routerName, boolean isLastRoute) { + List block = new ArrayList<>(); + var methodName = getGeneratedName(); + var returnType = getReturnType(); + var paramString = String.join(", ", getJavaMethodSignature(kt)); + var javadocLink = javadocComment(kt, routerName); + var attributeGenerator = new RouteAttributesGenerator(context, hasBeanValidation); + + var httpMethod = + HttpMethod.findByAnnotationName(httpMethodAnnotation.getQualifiedName().toString()); + var dslMethod = httpMethodAnnotation.getSimpleName().toString().toLowerCase(); + var paths = + context.path((TypeElement) method.getEnclosingElement(), method, httpMethodAnnotation); + var targetMethod = methodName; + + var thisRef = + isSuspendFun() ? "this@" + context.generateRouterName(routerName) + "::" : "this::"; + + for (var path : paths) { + var lastLine = isLastRoute && paths.get(paths.size() - 1).equals(path); + block.add(javadocLink); + block.add( + statement( + isSuspendFun() ? "" : "app.", + dslMethod, + "(", + string(leadingSlash(path)), + ", ", + context.pipeline( + getReturnType().getRawType(), methodReference(kt, thisRef, targetMethod)))); + + if (context.nonBlocking(getReturnType().getRawType()) || isSuspendFun()) { + block.add(statement(indent(2), ".setNonBlocking(true)")); + } + mediaType(httpMethod::consumes) + .ifPresent(consumes -> block.add(statement(indent(2), ".setConsumes(", consumes, ")"))); + mediaType(httpMethod::produces) + .ifPresent(produces -> block.add(statement(indent(2), ".setProduces(", produces, ")"))); + dispatch() + .ifPresent( + dispatch -> + block.add(statement(indent(2), ".setExecutorKey(", string(dispatch), ")"))); + + attributeGenerator + .toSourceCode(kt, this, 2) + .ifPresent( + attributes -> block.add(statement(indent(2), ".setAttributes(", attributes, ")"))); + + var lineSep = lastLine ? lineSeparator() : lineSeparator() + lineSeparator(); + + if (context.generateMvcMethod()) { + block.add( + CodeBlock.of( + indent(2), + ".setMvcMethod(", + kt ? "" : "new ", + "io.jooby.Route.MvcMethod(", + routerName, + clazz(kt), + ", ", + string(getMethodName()), + ", ", + type(kt, returnType.getRawType().toString()), + clazz(kt), + paramString.isEmpty() ? "" : ", " + paramString, + "))", + semicolon(kt), + lineSep)); + } else { + var lastStatement = block.get(block.size() - 1); + if (lastStatement.endsWith(lineSeparator())) { + lastStatement = + lastStatement.substring(0, lastStatement.length() - lineSeparator().length()); + } + block.set(block.size() - 1, lastStatement + semicolon(kt) + lineSep); + } + } + return block; + } + + public List generateHandlerCall(boolean kt) { + var buffer = new ArrayList(); + var methodName = getGeneratedName(); + var paramList = new StringJoiner(", ", "(", ")"); + var returnTypeGenerics = + getReturnType().getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); + var returnTypeString = type(kt, getReturnType().toString()); + var customReturnType = getReturnType(); + + if (customReturnType.isProjection()) { + returnTypeGenerics = ""; + returnTypeString = Types.PROJECTED + "<" + returnType + ">"; + } + + boolean nullable = + methodCallHeader( + kt, + "ctx", + methodName, + buffer, + returnTypeGenerics, + returnTypeString, + !method.getThrownTypes().isEmpty()); + + int controllerIndent = 2; + + for (var parameter : getParameters(true)) { + String generatedParameter = parameter.generateMapping(kt); + if (parameter.isRequireBeanValidation()) { + generatedParameter = + CodeBlock.of( + "io.jooby.validation.BeanValidator.apply(", "ctx, ", generatedParameter, ")"); + } + paramList.add(generatedParameter); + } + + buffer.add( + statement(indent(controllerIndent), var(kt), "c = this.factory.apply(ctx)", semicolon(kt))); + + if (returnType.isVoid()) { + String statusCode = + httpMethodAnnotation.getSimpleName().toString().equals("DELETE") ? "NO_CONTENT" : "OK"; + buffer.add( + statement( + indent(controllerIndent), + "ctx.setResponseCode(io.jooby.StatusCode.", + statusCode, + ")", + semicolon(kt))); + buffer.add( + statement( + indent(controllerIndent), + "c.", + this.method.getSimpleName(), + paramList.toString(), + semicolon(kt))); + buffer.add( + statement(indent(controllerIndent), "return ctx.getResponseCode()", semicolon(kt))); + } else if (returnType.is("io.jooby.StatusCode")) { + buffer.add( + statement( + indent(controllerIndent), + kt ? "val" : "var", + " statusCode = c.", + this.method.getSimpleName(), + paramList.toString(), + semicolon(kt))); + buffer.add( + statement(indent(controllerIndent), "ctx.setResponseCode(statusCode)", semicolon(kt))); + buffer.add(statement(indent(controllerIndent), "return statusCode", semicolon(kt))); + } else { + var castStr = + customReturnType.isProjection() + ? "" + : customReturnType.getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); + var needsCast = + !castStr.isEmpty() + || (kt + && !customReturnType.isProjection() + && !customReturnType.getArguments().isEmpty()); + var kotlinNotEnoughTypeInformation = !castStr.isEmpty() && kt ? "" : ""; + + var call = + of( + "c.", + this.method.getSimpleName(), + kotlinNotEnoughTypeInformation, + paramList.toString()); + + if (needsCast) { + setUncheckedCast(true); + call = kt ? call + " as " + returnTypeString : "(" + returnTypeString + ") " + call; + } + + if (customReturnType.isProjection()) { + var projected = + of(Types.PROJECTED, ".wrap(", call, ").include(", string(getProjection()), ")"); + buffer.add( + statement( + indent(controllerIndent), + "return ", + projected, + kt && nullable ? "!!" : "", + semicolon(kt))); + } else { + buffer.add( + statement( + indent(controllerIndent), + "return ", + call, + kt && nullable ? "!!" : "", + semicolon(kt))); + } + } + + buffer.add(statement("}", lineSeparator())); + + if (isUncheckedCast()) { + if (kt) buffer.addFirst(statement("@Suppress(\"UNCHECKED_CAST\")")); + else buffer.addFirst(statement("@SuppressWarnings(\"unchecked\")")); + } + + return buffer; + } + + private boolean methodCallHeader( + boolean kt, + String contextVarname, + String methodName, + ArrayList buffer, + String returnTypeGenerics, + String returnTypeString, + boolean throwsException) { + var nullable = false; + if (kt) { + nullable = + method.getAnnotationMirrors().stream() + .map(javax.lang.model.element.AnnotationMirror::getAnnotationType) + .map(java.util.Objects::toString) + .anyMatch(AnnotationSupport.NULLABLE); + if (throwsException) buffer.add(statement("@Throws(Exception::class)")); + + if (isSuspendFun()) { + buffer.add( + statement( + "suspend ", + "fun ", + returnTypeGenerics, + methodName, + "(handler: io.jooby.kt.HandlerContext): ", + returnTypeString, + " {")); + buffer.add(statement(indent(2), "val ", contextVarname, " = handler.ctx")); + } else { + buffer.add( + statement( + "fun ", + returnTypeGenerics, + methodName, + "(", + contextVarname, + ": io.jooby.Context): ", + returnTypeString, + " {")); + } + } else { + buffer.add( + statement( + "public ", + returnTypeGenerics, + returnTypeString, + " ", + methodName, + "(io.jooby.Context ", + contextVarname, + ") ", + throwsException ? "throws Exception {" : "{")); + } + return nullable; + } + + public String getProjection() { + var project = AnnotationSupport.findAnnotationByName(method, Types.PROJECT); + if (project != null) { + return AnnotationSupport.findAnnotationValue(project, VALUE).stream() + .findFirst() + .orElse(null); + } + var httpMethod = httpMethodAnnotation.getAnnotationMirrors().getFirst(); + var projection = AnnotationSupport.findAnnotationValue(httpMethod, "projection"::equals); + return projection.stream().findFirst().orElse(null); + } +} diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRouter.java new file mode 100644 index 0000000000..9307118a8a --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRouter.java @@ -0,0 +1,149 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.apt; + +import static io.jooby.internal.apt.CodeBlock.*; + +import java.io.IOException; +import java.util.stream.Collectors; + +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.TypeElement; + +public class RestRouter extends WebRouter { + + public RestRouter(MvcContext context, TypeElement clazz) { + super(context, clazz); + } + + public static RestRouter parse(MvcContext context, TypeElement controller) { + RestRouter router = new RestRouter(context, controller); + + for (var enclosed : controller.getEnclosedElements()) { + if (enclosed.getKind() == ElementKind.METHOD) { + ExecutableElement method = (ExecutableElement) enclosed; + + if (AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.JsonRpc") != null + || AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.Trpc") != null) { + continue; + } + + for (var annoMirror : method.getAnnotationMirrors()) { + TypeElement annoElement = (TypeElement) annoMirror.getAnnotationType().asElement(); + if (HttpMethod.hasAnnotation(annoElement)) { + RestRoute route = new RestRoute(router, method, annoElement); + router.routes.put(route.getMethodName() + annoElement.getSimpleName(), route); + } + } + } + } + + // Resolve Overloads + var grouped = + router.routes.values().stream().collect(Collectors.groupingBy(RestRoute::getMethodName)); + for (var overloads : grouped.values()) { + if (overloads.size() > 1) { + for (var route : overloads) { + var paramsString = + route.getRawParameterTypes(true, false).stream() + .map(it -> it.substring(Math.max(0, it.lastIndexOf(".") + 1))) + .map(it -> Character.toUpperCase(it.charAt(0)) + it.substring(1)) + .collect(Collectors.joining()); + route.setGeneratedName(route.getMethodName() + paramsString); + } + } + } + return router; + } + + @Override + public String getGeneratedType() { + return context.generateRouterName(getTargetType().getQualifiedName().toString()); + } + + @Override + public String getSourceCode(Boolean generateKotlin) throws IOException { + if (isEmpty()) return null; + + boolean kt = generateKotlin == Boolean.TRUE || isKt(); + var generateTypeName = getTargetType().getSimpleName().toString(); + var generatedClass = getGeneratedType().substring(getGeneratedType().lastIndexOf('.') + 1); + + var template = kt ? KOTLIN : JAVA; + var suspended = getRoutes().stream().filter(WebRoute::isSuspendFun).toList(); + var noSuspended = getRoutes().stream().filter(it -> !it.isSuspendFun()).toList(); + var buffer = new StringBuilder(); + + context.generateStaticImports( + this, + (owner, fn) -> + buffer.append( + statement("import ", kt ? "" : "static ", owner, ".", fn, semicolon(kt)))); + var imports = buffer.toString(); + buffer.setLength(0); + + if (kt) { + buffer.append(indent(4)).append("@Throws(Exception::class)").append(System.lineSeparator()); + buffer + .append(indent(4)) + .append("override fun install(app: io.jooby.Jooby) {") + .append(System.lineSeparator()); + } else { + buffer + .append(indent(4)) + .append("public void install(io.jooby.Jooby app) throws Exception {") + .append(System.lineSeparator()); + } + + if (!suspended.isEmpty()) { + buffer.append(statement(indent(6), "val kooby = app as io.jooby.kt.Kooby")); + buffer.append(statement(indent(6), "kooby.coroutine {")); + suspended.stream() + .flatMap( + it -> + it + .generateMapping( + kt, + generateTypeName, + suspended.indexOf(it) == suspended.size() - 1 && noSuspended.isEmpty()) + .stream()) + .forEach(line -> buffer.append(CodeBlock.indent(8)).append(line)); + trimr(buffer); + buffer.append(System.lineSeparator()).append(statement(indent(6), "}")); + } + + noSuspended.stream() + .flatMap( + it -> + it + .generateMapping( + kt, generateTypeName, noSuspended.indexOf(it) == noSuspended.size() - 1) + .stream()) + .forEach(line -> buffer.append(CodeBlock.indent(6)).append(line)); + + trimr(buffer); + buffer + .append(System.lineSeparator()) + .append(indent(4)) + .append("}") + .append(System.lineSeparator()) + .append(System.lineSeparator()); + + getRoutes().stream() + .flatMap(it -> it.generateHandlerCall(kt).stream()) + .forEach(line -> buffer.append(CodeBlock.indent(4)).append(line)); + + return template + .replace("${packageName}", getPackageName()) + .replace("${imports}", imports) + .replace("${className}", generateTypeName) + .replace("${generatedClassName}", generatedClass) + .replace("${implements}", "io.jooby.Extension") + .replace("${constructors}", constructors(generatedClass, kt)) + .replace("${methods}", trimr(buffer)); + } +} diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RouteAttributesGenerator.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RouteAttributesGenerator.java index 3827c6edff..0c2f9a3ecb 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RouteAttributesGenerator.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RouteAttributesGenerator.java @@ -58,7 +58,7 @@ public RouteAttributesGenerator(MvcContext context, boolean hasBeanValidation) { this.hasBeanValidation = hasBeanValidation; } - public Optional toSourceCode(boolean kt, MvcRoute route, int indent) { + public Optional toSourceCode(boolean kt, WebRoute route, int indent) { var attributes = annotationMap(route.getMethod()); if (attributes.isEmpty()) { return Optional.empty(); diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRoute.java new file mode 100644 index 0000000000..d9d1a916d2 --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRoute.java @@ -0,0 +1,341 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.apt; + +import static io.jooby.internal.apt.AnnotationSupport.VALUE; +import static io.jooby.internal.apt.CodeBlock.*; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.type.TypeKind; + +public class TrpcRoute extends WebRoute { + private final HttpMethod resolvedTrpcMethod; + private String generatedName; + + public TrpcRoute(WebRouter router, ExecutableElement method) { + super(router, method); + this.resolvedTrpcMethod = discoverTrpcMethod(); + this.generatedName = method.getSimpleName().toString(); + } + + public void setGeneratedName(String generatedName) { + this.generatedName = generatedName; + } + + public String getGeneratedName() { + return generatedName; + } + + private HttpMethod discoverTrpcMethod() { + if (AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.Trpc.Query") != null) + return HttpMethod.GET; + if (AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.Trpc.Mutation") != null) + return HttpMethod.POST; + if (AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.Trpc") != null) { + if (HttpMethod.GET.matches(method)) return HttpMethod.GET; + return HttpMethod.POST; // Default fallback for @Trpc missing explicit Query/Mutation mapping + } + return null; + } + + public String trpcPath() { + var namespace = + Optional.ofNullable( + AnnotationSupport.findAnnotationByName( + method.getEnclosingElement(), "io.jooby.annotation.Trpc")) + .flatMap(it -> AnnotationSupport.findAnnotationValue(it, VALUE).stream().findFirst()) + .map(it -> it + ".") + .orElse(""); + + var procedure = + Stream.of( + "io.jooby.annotation.Trpc.Query", + "io.jooby.annotation.Trpc.Mutation", + "io.jooby.annotation.Trpc") + .map(it -> AnnotationSupport.findAnnotationByName(method, it)) + .filter(Objects::nonNull) + .findFirst() + .flatMap(it -> AnnotationSupport.findAnnotationValue(it, VALUE).stream().findFirst()) + .orElse(method.getSimpleName().toString()); + + return Stream.of("trpc", namespace + procedure) + .map(segment -> segment.startsWith("/") ? segment.substring(1) : segment) + .collect(Collectors.joining("/", "/", "")); + } + + public List generateMapping(boolean kt, String routerName) { + List block = new ArrayList<>(); + var targetMethod = + "trpc" + generatedName.substring(0, 1).toUpperCase() + generatedName.substring(1); + var dslMethod = resolvedTrpcMethod.name().toLowerCase(); + var path = trpcPath(); + + var thisRef = + isSuspendFun() ? "this@" + context.generateRouterName(routerName) + "::" : "this::"; + + block.add( + CodeBlock.statement( + kt + ? "/** See [" + routerName + "." + getMethodName() + "] */" + : "/** See {@link " + routerName + "#" + getMethodName() + "} */")); + block.add( + statement( + isSuspendFun() ? "" : "app.", + dslMethod, + "(", + string(path.startsWith("/") ? path : "/" + path), + ", ", + context.pipeline( + getReturnType().getRawType(), methodReference(kt, thisRef, targetMethod)))); + + if (context.nonBlocking(getReturnType().getRawType()) || isSuspendFun()) + block.add(statement(indent(2), ".setNonBlocking(true)")); + + var lastStatement = block.get(block.size() - 1); + block.set( + block.size() - 1, + lastStatement + semicolon(kt) + System.lineSeparator() + System.lineSeparator()); + + return block; + } + + private String methodReference(boolean kt, String thisRef, String methodName) { + if (kt) { + var generics = returnType.getArgumentsString(kt, true, Set.of(TypeKind.TYPEVAR)); + if (!generics.isEmpty()) return CodeBlock.of(") { ctx -> ", methodName, generics, "(ctx) }"); + } + return thisRef + methodName + ")"; + } + + public List generateHandlerCall(boolean kt) { + var buffer = new ArrayList(); + var methodName = + "trpc" + generatedName.substring(0, 1).toUpperCase() + generatedName.substring(1); + var paramList = new StringJoiner(", ", "(", ")"); + + var returnTypeGenerics = + getReturnType().getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); + var returnTypeString = type(kt, getReturnType().toString()); + + var reactive = context.getReactiveType(returnType.getRawType()); + var isReactiveVoid = false; + var innerReactiveType = "Object"; + var methodReturnTypeString = returnTypeString; + + if (reactive != null) { + var rawReactiveType = type(kt, returnType.getRawType().toString()); + if (!returnType.getArguments().isEmpty()) { + innerReactiveType = type(kt, returnType.getArguments().get(0).getRawType().toString()); + if (innerReactiveType.equals("java.lang.Void") || innerReactiveType.equals("Void")) { + isReactiveVoid = true; + innerReactiveType = kt ? "Unit" : "Void"; + } + } else if (rawReactiveType.contains("Completable")) { + isReactiveVoid = true; + innerReactiveType = kt ? "Unit" : "Void"; + } + methodReturnTypeString = + rawReactiveType + ">"; + } else { + methodReturnTypeString = + "io.jooby.rpc.trpc.TrpcResponse<" + + (returnType.isVoid() ? (kt ? "Unit" : "Void") : returnTypeString) + + ">"; + } + + if (kt) { + buffer.add( + statement( + "fun ", + returnTypeGenerics, + methodName, + "(ctx: io.jooby.Context): ", + methodReturnTypeString, + " {")); + } else { + buffer.add( + statement( + "public ", + returnTypeGenerics, + methodReturnTypeString, + " ", + methodName, + "(io.jooby.Context ctx) throws Exception {")); + } + + int controllerIndent = parameters.isEmpty() ? 2 : 4; + + if (!parameters.isEmpty()) { + buffer.add( + statement( + indent(2), + var(kt), + "parser = ctx.require(io.jooby.rpc.trpc.TrpcParser", + clazz(kt), + ")", + semicolon(kt))); + long payloadCount = + parameters.stream() + .filter( + p -> { + String t = p.getType().getRawType().toString(); + return !t.equals("io.jooby.Context") + && !p.getType().is("kotlin.coroutines.Continuation"); + }) + .count(); + boolean isTuple = payloadCount > 1; + + if (resolvedTrpcMethod == HttpMethod.GET) { + buffer.add( + statement( + indent(2), + var(kt), + "input = ctx.query(", + string("input"), + ").value()", + semicolon(kt))); + if (isTuple) { + if (kt) + buffer.add( + statement( + indent(2), + "if (input?.trim()?.let { it.startsWith('[') && it.endsWith(']') } != true)" + + " throw IllegalArgumentException(", + string("tRPC input for multiple arguments must be a JSON array (tuple)"), + ")")); + else + buffer.add( + statement( + indent(2), + "if (input == null || input.length() < 2 || input.charAt(0) != '[' ||" + + " input.charAt(input.length() - 1) != ']') throw new" + + " IllegalArgumentException(", + string("tRPC input for multiple arguments must be a JSON array (tuple)"), + ");")); + } + } else { + buffer.add(statement(indent(2), var(kt), "input = ctx.body().bytes()", semicolon(kt))); + if (isTuple) { + if (kt) + buffer.add( + statement( + indent(2), + "if (input.size < 2 || input[0] != '['.code.toByte() || input[input.size - 1]" + + " != ']'.code.toByte()) throw IllegalArgumentException(", + string("tRPC body for multiple arguments must be a JSON array (tuple)"), + ")")); + else + buffer.add( + statement( + indent(2), + "if (input.length < 2 || input[0] != '[' || input[input.length - 1] != ']')" + + " throw new IllegalArgumentException(", + string("tRPC body for multiple arguments must be a JSON array (tuple)"), + ");")); + } + } + + if (kt) { + buffer.add( + statement( + indent(2), "parser.reader(input, ", String.valueOf(isTuple), ").use { reader -> ")); + } else { + buffer.add( + statement( + indent(2), + "try (var reader = parser.reader(input, ", + String.valueOf(isTuple), + ")) {")); + } + + // Read parameters (re-using the logic concept from before) + for (var parameter : parameters) { + var paramenterName = parameter.getName(); + if (parameter.getType().getRawType().toString().equals("io.jooby.Context")) { + paramList.add("ctx"); + continue; + } + var type = type(kt, parameter.getType().toString()); + if (kt) { + buffer.add( + statement( + indent(4), + "val ", + paramenterName, + "Decoder: io.jooby.rpc.trpc.TrpcDecoder<", + type, + "> = parser.decoder(", + parameter.getType().toSourceCode(kt), + ")")); + buffer.add( + statement( + indent(4), + "val ", + paramenterName, + " = reader.nextObject(", + string(paramenterName), + ", ", + paramenterName, + "Decoder)")); + } else { + buffer.add( + statement( + indent(4), + "io.jooby.rpc.trpc.TrpcDecoder<", + type, + "> ", + paramenterName, + "Decoder = parser.decoder(", + parameter.getType().toSourceCode(kt), + ")", + semicolon(false))); + buffer.add( + statement( + indent(4), + type, + " ", + paramenterName, + " = reader.nextObject(", + string(paramenterName), + ", ", + paramenterName, + "Decoder)", + semicolon(false))); + } + paramList.add(paramenterName); + } + } + + buffer.add( + statement(indent(controllerIndent), var(kt), "c = this.factory.apply(ctx)", semicolon(kt))); + var call = CodeBlock.of("c.", this.method.getSimpleName(), paramList.toString()); + + if (returnType.isVoid()) { + buffer.add(statement(indent(controllerIndent), call, semicolon(kt))); + buffer.add( + statement( + indent(controllerIndent), + "return io.jooby.rpc.trpc.TrpcResponse.empty()", + semicolon(kt))); + } else { + buffer.add( + statement( + indent(controllerIndent), + "return io.jooby.rpc.trpc.TrpcResponse.of(", + call, + ")", + semicolon(kt))); + } + + if (!parameters.isEmpty()) buffer.add(statement(indent(2), "}")); + buffer.add(statement("}", System.lineSeparator())); + return buffer; + } +} diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRouter.java new file mode 100644 index 0000000000..bbd5746049 --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRouter.java @@ -0,0 +1,120 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.apt; + +import static io.jooby.internal.apt.CodeBlock.*; + +import java.io.IOException; +import java.util.stream.Collectors; + +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.TypeElement; + +public class TrpcRouter extends WebRouter { + + public TrpcRouter(MvcContext context, TypeElement clazz) { + super(context, clazz); + } + + public static TrpcRouter parse(MvcContext context, TypeElement controller) { + var router = new TrpcRouter(context, controller); + for (var enclosed : controller.getEnclosedElements()) { + if (enclosed.getKind() == ElementKind.METHOD) { + ExecutableElement method = (ExecutableElement) enclosed; + if (AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.Trpc") != null + || AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.Trpc.Query") + != null + || AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.Trpc.Mutation") + != null) { + TrpcRoute route = new TrpcRoute(router, method); + router.routes.put(route.getMethodName(), route); + } + } + } + + // Resolve Overloads + var grouped = + router.routes.values().stream().collect(Collectors.groupingBy(TrpcRoute::getMethodName)); + for (var overloads : grouped.values()) { + if (overloads.size() > 1) { + for (var route : overloads) { + var paramsString = + route.getRawParameterTypes(true, false).stream() + .map(it -> it.substring(Math.max(0, it.lastIndexOf(".") + 1))) + .map(it -> Character.toUpperCase(it.charAt(0)) + it.substring(1)) + .collect(Collectors.joining()); + route.setGeneratedName(route.getMethodName() + paramsString); + } + } + } + return router; + } + + @Override + public String getGeneratedType() { + return context.generateRouterName(getTargetType().getQualifiedName().toString() + "Trpc"); + } + + @Override + public String getSourceCode(Boolean generateKotlin) throws IOException { + if (isEmpty()) return null; + + boolean kt = generateKotlin == Boolean.TRUE || isKt(); + var generateTypeName = getTargetType().getSimpleName().toString(); + var generatedClass = getGeneratedType().substring(getGeneratedType().lastIndexOf('.') + 1); + + var template = kt ? KOTLIN : JAVA; + var buffer = new StringBuilder(); + + context.generateStaticImports( + this, + (owner, fn) -> + buffer.append( + statement("import ", kt ? "" : "static ", owner, ".", fn, semicolon(kt)))); + var imports = buffer.toString(); + buffer.setLength(0); + + // begin install + if (kt) { + buffer.append(indent(4)).append("@Throws(Exception::class)").append(System.lineSeparator()); + buffer + .append(indent(4)) + .append("override fun install(app: io.jooby.Jooby) {") + .append(System.lineSeparator()); + } else { + buffer + .append(indent(4)) + .append("public void install(io.jooby.Jooby app) throws Exception {") + .append(System.lineSeparator()); + } + + getRoutes().stream() + .flatMap(it -> it.generateMapping(kt, generateTypeName).stream()) + .forEach(line -> buffer.append(CodeBlock.indent(6)).append(line)); + + trimr(buffer); + buffer + .append(System.lineSeparator()) + .append(indent(4)) + .append("}") + .append(System.lineSeparator()) + .append(System.lineSeparator()); + + getRoutes().stream() + .flatMap(it -> it.generateHandlerCall(kt).stream()) + .forEach(line -> buffer.append(CodeBlock.indent(4)).append(line)); + + return template + .replace("${packageName}", getPackageName()) + .replace("${imports}", imports) + .replace("${className}", generateTypeName) + .replace("${generatedClassName}", generatedClass) + .replace("${implements}", "io.jooby.Extension") + .replace("${constructors}", constructors(generatedClass, kt)) + .replace("${methods}", trimr(buffer)); + } +} diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRoute.java new file mode 100644 index 0000000000..688b3bd270 --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRoute.java @@ -0,0 +1,139 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.apt; + +import static io.jooby.internal.apt.CodeBlock.clazz; +import static io.jooby.internal.apt.CodeBlock.type; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; + +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.type.TypeMirror; +import javax.lang.model.type.WildcardType; + +public abstract class WebRoute { + protected final MvcContext context; + protected final ExecutableElement method; + protected final List parameters; + protected final TypeDefinition returnType; + protected final boolean suspendFun; + protected final boolean hasBeanValidation; + protected final WebRouter router; + private boolean uncheckedCast; + + public WebRoute(WebRouter router, ExecutableElement method) { + this.context = router.context; + this.router = router; + this.method = method; + this.parameters = + method.getParameters().stream().map(it -> new MvcParameter(context, this, it)).toList(); + this.hasBeanValidation = parameters.stream().anyMatch(MvcParameter::isRequireBeanValidation); + this.suspendFun = + !parameters.isEmpty() + && parameters.get(parameters.size() - 1).getType().is("kotlin.coroutines.Continuation"); + this.returnType = + new TypeDefinition( + context.getProcessingEnvironment().getTypeUtils(), method.getReturnType()); + } + + public WebRouter getRouter() { + return router; + } + + public MvcContext getContext() { + return context; + } + + public ExecutableElement getMethod() { + return method; + } + + public String getMethodName() { + return method.getSimpleName().toString(); + } + + public boolean isSuspendFun() { + return suspendFun; + } + + public boolean hasBeanValidation() { + return hasBeanValidation; + } + + public List getParameters(boolean skipCoroutine) { + return parameters.stream() + .filter(type -> !skipCoroutine || !type.getType().is("kotlin.coroutines.Continuation")) + .toList(); + } + + public TypeDefinition getReturnType() { + var processingEnv = context.getProcessingEnvironment(); + var types = processingEnv.getTypeUtils(); + var elements = processingEnv.getElementUtils(); + + if (returnType.isVoid()) { + return new TypeDefinition(types, elements.getTypeElement("io.jooby.StatusCode").asType()); + } else if (isSuspendFun()) { + var continuation = parameters.get(parameters.size() - 1).getType(); + if (!continuation.getArguments().isEmpty()) { + var continuationReturnType = continuation.getArguments().get(0).getType(); + if (continuationReturnType instanceof WildcardType wildcardType) { + return Stream.of(wildcardType.getSuperBound(), wildcardType.getExtendsBound()) + .filter(Objects::nonNull) + .findFirst() + .map(e -> new TypeDefinition(types, e)) + .orElseGet(() -> new TypeDefinition(types, continuationReturnType)); + } else { + return new TypeDefinition(types, continuationReturnType); + } + } + } + return returnType; + } + + public List getRawParameterTypes(boolean skipCoroutine, boolean kt) { + return getParameters(skipCoroutine).stream() + .map(MvcParameter::getType) + .map(TypeDefinition::getRawType) + .map(TypeMirror::toString) + .map(it -> type(kt, it)) + .toList(); + } + + /** + * Returns the return type of the route method. Used to determine if the route returns a reactive + * type that requires static imports. + * + * @return The return type of the route handler. + */ + public TypeMirror getReturnTypeHandler() { + return getReturnType().getRawType(); + } + + public boolean isUncheckedCast() { + return uncheckedCast; + } + + public void setUncheckedCast(boolean value) { + this.uncheckedCast = value; + } + + public List getJavaMethodSignature(boolean kt) { + return getParameters(false).stream() + .map( + it -> { + var type = it.getType(); + if (kt && type.isPrimitive()) { + return type(kt, type.getRawType().toString()); + } + return type.getRawType().toString(); + }) + .map(it -> it + clazz(kt)) + .toList(); + } +} diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRouter.java new file mode 100644 index 0000000000..24f4a9d123 --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRouter.java @@ -0,0 +1,323 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.apt; + +import static io.jooby.internal.apt.AnnotationSupport.findAnnotationByName; +import static io.jooby.internal.apt.CodeBlock.indent; +import static io.jooby.internal.apt.CodeBlock.semicolon; + +import java.io.IOException; +import java.util.*; +import java.util.function.BiConsumer; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.TypeElement; + +public abstract class WebRouter { + public static final String JAVA = + """ + package ${packageName}; + ${imports} + @io.jooby.annotation.Generated(${className}.class) + public class ${generatedClassName} implements ${implements} { + protected java.util.function.Function factory; + ${constructors} + public ${generatedClassName}(${className} instance) { + setup(ctx -> instance); + } + + public ${generatedClassName}(io.jooby.SneakyThrows.Supplier<${className}> provider) { + setup(ctx -> (${className}) provider.get()); + } + + public ${generatedClassName}(io.jooby.SneakyThrows.Function, ${className}> provider) { + setup(ctx -> provider.apply(${className}.class)); + } + + private void setup(java.util.function.Function factory) { + this.factory = factory; + } + + ${methods} + } + + """; + public static final String KOTLIN = + """ + package ${packageName} + ${imports} + @io.jooby.annotation.Generated(${className}::class) + open class ${generatedClassName} : ${implements} { + private lateinit var factory: java.util.function.Function + + ${constructors} + constructor(instance: ${className}) { setup { instance } } + + constructor(provider: io.jooby.SneakyThrows.Supplier<${className}>) { setup { provider.get() } } + + constructor(provider: (Class<${className}>) -> ${className}) { setup { provider(${className}::class.java) } } + + constructor(provider: io.jooby.SneakyThrows.Function, ${className}>) { setup { provider.apply(${className}::class.java) } } + + private fun setup(factory: java.util.function.Function) { + this.factory = factory + } + ${methods} + } + + """; + + protected final MvcContext context; + protected final TypeElement clazz; + protected final Map routes = new LinkedHashMap<>(); + + public WebRouter(MvcContext context, TypeElement clazz) { + this.context = context; + this.clazz = clazz; + } + + public abstract String getGeneratedType(); + + public abstract String getSourceCode(Boolean generateKotlin) throws IOException; + + public String getGeneratedFilename() { + return getGeneratedType().replace('.', '/') + (isKt() ? ".kt" : ".java"); + } + + public TypeElement getTargetType() { + return clazz; + } + + public List getRoutes() { + return new ArrayList<>(routes.values()); + } + + public boolean isEmpty() { + return routes.isEmpty(); + } + + public boolean isAbstract() { + return clazz.getModifiers().contains(Modifier.ABSTRACT); + } + + public boolean isKt() { + return context + .getProcessingEnvironment() + .getElementUtils() + .getAllAnnotationMirrors(getTargetType()) + .stream() + .anyMatch(it -> it.getAnnotationType().asElement().toString().equals("kotlin.Metadata")); + } + + public String getPackageName() { + var classname = getGeneratedType(); + var pkgEnd = classname.lastIndexOf('.'); + return pkgEnd > 0 ? classname.substring(0, pkgEnd) : ""; + } + + public boolean hasBeanValidation() { + return getRoutes().stream().anyMatch(WebRoute::hasBeanValidation); + } + + protected StringBuilder trimr(StringBuilder buffer) { + var i = buffer.length() - 1; + while (i > 0 && Character.isWhitespace(buffer.charAt(i))) { + buffer.deleteCharAt(i); + i = buffer.length() - 1; + } + return buffer; + } + + protected StringBuilder constructors(String generatedName, boolean kt) { + var constructors = + getTargetType().getEnclosedElements().stream() + .filter( + it -> + it.getKind() == ElementKind.CONSTRUCTOR + && it.getModifiers().contains(Modifier.PUBLIC)) + .map(ExecutableElement.class::cast) + .toList(); + var targetType = getTargetType().getSimpleName(); + var buffer = new StringBuilder(); + buffer.append(System.lineSeparator()); + // Inject could be at constructor or field level. + var injectConstructor = + constructors.stream().filter(hasInjectAnnotation()).findFirst().orElse(null); + var inject = injectConstructor != null || hasInjectAnnotation(getTargetType()); + final var defaultConstructor = + constructors.stream().filter(it -> it.getParameters().isEmpty()).findFirst().orElse(null); + if (inject) { + constructor( + generatedName, + kt, + kt ? ":" : null, + buffer, + List.of(), + (output, params) -> { + output + .append("this(") + .append(targetType) + .append(kt ? "::class" : ".class") + .append(")") + .append(semicolon(kt)) + .append(System.lineSeparator()); + }); + } else { + if (defaultConstructor != null) { + constructor( + generatedName, + kt, + kt ? ":" : null, + buffer, + List.of(), + (output, params) -> { + if (kt) { + output + .append("this(") + .append(targetType) + .append("())") + .append(semicolon(true)) + .append(System.lineSeparator()); + } else { + output + .append("this(") + .append("io.jooby.SneakyThrows.singleton(") + .append(targetType) + .append("::new))") + .append(semicolon(false)) + .append(System.lineSeparator()); + } + }); + } + } + var skip = + Stream.of(injectConstructor, defaultConstructor) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + for (ExecutableElement constructor : constructors) { + if (!skip.contains(constructor)) { + constructor( + generatedName, + kt, + kt ? ":" : null, + buffer, + constructor.getParameters().stream() + .map(it -> Map.entry(it.asType(), it.getSimpleName().toString())) + .toList(), + (output, params) -> { + var separator = ", "; + output.append("this(").append(kt ? "" : "new ").append(targetType).append("("); + params.forEach(e -> output.append(e.getValue()).append(separator)); + output.setLength(output.length() - separator.length()); + output.append("))").append(semicolon(kt)).append(System.lineSeparator()); + }); + } + } + + if (inject) { + if (kt) { + constructor( + generatedName, + true, + "{", + buffer, + List.of(Map.entry("kotlin.reflect.KClass<" + targetType + ">", "type")), + (output, params) -> { + output + .append("setup { ctx -> ctx.require<") + .append(targetType) + .append(">(type.java)") + .append(" }") + .append(System.lineSeparator()); + }); + } else { + constructor( + generatedName, + false, + null, + buffer, + List.of(Map.entry("Class<" + targetType + ">", "type")), + (output, params) -> { + output + .append("setup(") + .append("ctx -> ctx.require(type)") + .append(")") + .append(";") + .append(System.lineSeparator()); + }); + } + } + + return trimr(buffer).append(System.lineSeparator()); + } + + private void constructor( + String generatedName, + boolean kt, + String ktBody, + StringBuilder buffer, + List> parameters, + BiConsumer>> body) { + buffer.append(indent(4)); + if (kt) { + buffer.append("constructor").append("("); + } else { + buffer.append("public ").append(generatedName).append("("); + } + var separator = ", "; + parameters.forEach( + e -> { + if (kt) { + buffer.append(e.getValue()).append(": ").append(e.getKey()).append(separator); + } else { + buffer.append(e.getKey()).append(" ").append(e.getValue()).append(separator); + } + }); + if (!parameters.isEmpty()) { + buffer.setLength(buffer.length() - separator.length()); + } + buffer.append(")"); + if (!kt) { + buffer.append(" {").append(System.lineSeparator()); + buffer.append(indent(6)); + } else { + buffer.append(" ").append(ktBody).append(" "); + } + body.accept(buffer, parameters); + if (!kt || "{".equals(ktBody)) { + buffer.append(indent(4)).append("}"); + } + buffer.append(System.lineSeparator()).append(System.lineSeparator()); + } + + private boolean hasInjectAnnotation(TypeElement targetClass) { + var inject = false; + while (!inject && !targetClass.toString().equals("java.lang.Object")) { + inject = targetClass.getEnclosedElements().stream().anyMatch(hasInjectAnnotation()); + targetClass = + (TypeElement) + context + .getProcessingEnvironment() + .getTypeUtils() + .asElement(targetClass.getSuperclass()); + } + return inject; + } + + private static Predicate hasInjectAnnotation() { + var injectAnnotations = + Set.of("javax.inject.Inject", "jakarta.inject.Inject", "com.google.inject.Inject"); + return it -> + injectAnnotations.stream() + .anyMatch(annotation -> findAnnotationByName(it, annotation) != null); + } +} diff --git a/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java b/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java index 15f39a62da..b8eb3f8304 100644 --- a/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java +++ b/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java @@ -79,7 +79,7 @@ protected void onGeneratedSource(String classname, JavaFileObject source) { javaFiles.put(classname, source); try { // Generate kotlin source code inside the compiler scope... avoid false positive errors - kotlinFiles.put(classname, context.getRouters().get(0).getRestSourceCode(true)); + kotlinFiles.put(classname, context.getRouters().get(0).getSourceCode(true)); } catch (IOException e) { SneakyThrows.propagate(e); } diff --git a/modules/jooby-apt/src/test/java/tests/HandlerCompilerTest.java b/modules/jooby-apt/src/test/java/tests/HandlerCompilerTest.java index a5c773a396..04e597fd09 100644 --- a/modules/jooby-apt/src/test/java/tests/HandlerCompilerTest.java +++ b/modules/jooby-apt/src/test/java/tests/HandlerCompilerTest.java @@ -534,6 +534,7 @@ public void body() throws Exception { @Test public void jarxs() throws Exception { new ProcessorRunner(new JaxrsController()) + .withSourceCode(System.out::println) .withRouter( app -> { MockRouter router = new MockRouter(app); diff --git a/modules/jooby-apt/src/test/java/tests/i3804/Issue3804.java b/modules/jooby-apt/src/test/java/tests/i3804/Issue3804.java index 3fa12c9de3..59279046ca 100644 --- a/modules/jooby-apt/src/test/java/tests/i3804/Issue3804.java +++ b/modules/jooby-apt/src/test/java/tests/i3804/Issue3804.java @@ -17,7 +17,6 @@ public void shouldDetectDIOnFieldsOfBaseClass() throws Exception { new ProcessorRunner(new C3804()) .withSourceCode( source -> { - System.out.println(source); assertTrue(source.contains("setup(ctx -> ctx.require(type));")); }); } From df7590a2a04a2feb613a9fa4096cb95f8a5544c3 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Wed, 25 Mar 2026 19:54:38 -0300 Subject: [PATCH 06/37] build: cleanup after huge refactor - get back tests passed --- .../java/io/jooby/internal/apt/McpRouter.java | 2 +- .../java/io/jooby/internal/apt/RestRoute.java | 66 ++-- .../io/jooby/internal/apt/RestRouter.java | 44 ++- .../java/io/jooby/internal/apt/TrpcRoute.java | 316 +++++++++++++++--- .../io/jooby/internal/apt/TrpcRouter.java | 37 +- .../java/io/jooby/internal/apt/WebRouter.java | 28 +- .../java/io/jooby/apt/ProcessorRunner.java | 32 ++ .../test/java/tests/HandlerCompilerTest.java | 1 - .../src/test/java/tests/i3830/Issue3830.java | 2 +- .../src/test/java/tests/i3853/C3853.java | 8 +- .../src/test/java/tests/i3863/Issue3863.java | 22 +- .../test/java/tests/i3863/OverloadTrpc.java | 2 +- .../src/test/java/tests/i3864/Issue3868.java | 14 +- 13 files changed, 440 insertions(+), 134 deletions(-) diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java index e9d30bcb2c..fcfebd5c44 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java @@ -39,7 +39,7 @@ public static McpRouter parse(MvcContext context, TypeElement controller) { @Override public String getGeneratedType() { - return context.generateRouterName(getTargetType().getQualifiedName().toString() + "Mcp"); + return context.generateRouterName(getTargetType().getQualifiedName() + "Mcp"); } public String getMcpServerKey() { diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java index e3b68760bf..d2818fa539 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java @@ -66,7 +66,7 @@ private String methodReference(boolean kt, String thisRef, String methodName) { private Optional dispatch() { var dispatch = dispatch(method); - return dispatch.isEmpty() ? dispatch(method.getEnclosingElement()) : dispatch; + return dispatch.isEmpty() ? dispatch(router.getTargetType()) : dispatch; } private Optional dispatch(Element element) { @@ -79,7 +79,7 @@ private Optional dispatch(Element element) { } private Optional mediaType(Function> lookup) { - var scopes = List.of(method, method.getEnclosingElement()); + var scopes = List.of(method, router.getTargetType()); var i = 0; var types = Collections.emptyList(); while (types.isEmpty() && i < scopes.size()) { @@ -119,8 +119,7 @@ public List generateMapping(boolean kt, String routerName, boolean isLas var httpMethod = HttpMethod.findByAnnotationName(httpMethodAnnotation.getQualifiedName().toString()); var dslMethod = httpMethodAnnotation.getSimpleName().toString().toLowerCase(); - var paths = - context.path((TypeElement) method.getEnclosingElement(), method, httpMethodAnnotation); + var paths = context.path(router.getTargetType(), method, httpMethodAnnotation); var targetMethod = methodName; var thisRef = @@ -192,14 +191,26 @@ public List generateHandlerCall(boolean kt) { var buffer = new ArrayList(); var methodName = getGeneratedName(); var paramList = new StringJoiner(", ", "(", ")"); - var returnTypeGenerics = - getReturnType().getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); - var returnTypeString = type(kt, getReturnType().toString()); + var customReturnType = getReturnType(); + var returnTypeGenerics = + customReturnType.getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); + var returnTypeString = type(kt, customReturnType.toString()); + + String projection = getProjection(); + + // Bulletproof check: Is the controller natively returning a Projected type? + boolean isProjectedReturnType = + customReturnType.isProjection() || customReturnType.is(Types.PROJECTED); - if (customReturnType.isProjection()) { - returnTypeGenerics = ""; - returnTypeString = Types.PROJECTED + "<" + returnType + ">"; + // 1. Create separate variables for the generated HTTP handler's signature + String handlerTypeGenerics = returnTypeGenerics; + String handlerTypeString = returnTypeString; + + // 2. ONLY modify the signature if we need to wrap a NON-projected type + if (projection != null && !isProjectedReturnType) { + handlerTypeGenerics = ""; + handlerTypeString = Types.PROJECTED + "<" + returnTypeString + ">"; } boolean nullable = @@ -208,8 +219,8 @@ public List generateHandlerCall(boolean kt) { "ctx", methodName, buffer, - returnTypeGenerics, - returnTypeString, + handlerTypeGenerics, + handlerTypeString, !method.getThrownTypes().isEmpty()); int controllerIndent = 2; @@ -260,14 +271,12 @@ public List generateHandlerCall(boolean kt) { buffer.add(statement(indent(controllerIndent), "return statusCode", semicolon(kt))); } else { var castStr = - customReturnType.isProjection() + isProjectedReturnType ? "" : customReturnType.getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); - var needsCast = - !castStr.isEmpty() - || (kt - && !customReturnType.isProjection() - && !customReturnType.getArguments().isEmpty()); + + // Force cast only if the return type contains Type Variables (like ) + var needsCast = !castStr.isEmpty(); var kotlinNotEnoughTypeInformation = !castStr.isEmpty() && kt ? "" : ""; var call = @@ -279,12 +288,13 @@ public List generateHandlerCall(boolean kt) { if (needsCast) { setUncheckedCast(true); + // Use the RAW return type string for the cast, not the modified handler type call = kt ? call + " as " + returnTypeString : "(" + returnTypeString + ") " + call; } - if (customReturnType.isProjection()) { - var projected = - of(Types.PROJECTED, ".wrap(", call, ").include(", string(getProjection()), ")"); + // 3. ONLY wrap the call if it's a NON-projected type with a projection string + if (projection != null && !isProjectedReturnType) { + var projected = of(Types.PROJECTED, ".wrap(", call, ").include(", string(projection), ")"); buffer.add( statement( indent(controllerIndent), @@ -376,8 +386,16 @@ public String getProjection() { .findFirst() .orElse(null); } - var httpMethod = httpMethodAnnotation.getAnnotationMirrors().getFirst(); - var projection = AnnotationSupport.findAnnotationValue(httpMethod, "projection"::equals); - return projection.stream().findFirst().orElse(null); + + // Get the annotation mirror from the method, not the annotation type definition + var httpMethodMirror = + AnnotationSupport.findAnnotationByName( + method, httpMethodAnnotation.getQualifiedName().toString()); + if (httpMethodMirror != null) { + var projection = + AnnotationSupport.findAnnotationValue(httpMethodMirror, "projection"::equals); + return projection.stream().findFirst().orElse(null); + } + return null; } } diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRouter.java index 9307118a8a..aca3e6d82b 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRouter.java @@ -23,20 +23,32 @@ public RestRouter(MvcContext context, TypeElement clazz) { public static RestRouter parse(MvcContext context, TypeElement controller) { RestRouter router = new RestRouter(context, controller); - for (var enclosed : controller.getEnclosedElements()) { - if (enclosed.getKind() == ElementKind.METHOD) { - ExecutableElement method = (ExecutableElement) enclosed; - - if (AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.JsonRpc") != null - || AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.Trpc") != null) { - continue; - } + for (TypeElement type : context.superTypes(controller)) { + for (var enclosed : type.getEnclosedElements()) { + if (enclosed.getKind() == ElementKind.METHOD) { + ExecutableElement method = (ExecutableElement) enclosed; + + // Ignore abstract methods + if (method.getModifiers().contains(javax.lang.model.element.Modifier.ABSTRACT)) { + continue; + } - for (var annoMirror : method.getAnnotationMirrors()) { - TypeElement annoElement = (TypeElement) annoMirror.getAnnotationType().asElement(); - if (HttpMethod.hasAnnotation(annoElement)) { - RestRoute route = new RestRoute(router, method, annoElement); - router.routes.put(route.getMethodName() + annoElement.getSimpleName(), route); + for (var annoMirror : method.getAnnotationMirrors()) { + TypeElement annoElement = (TypeElement) annoMirror.getAnnotationType().asElement(); + String annoName = annoElement.getQualifiedName().toString(); + + // Explicitly ignore RPC annotations so they don't generate invalid REST routes + if (annoName.startsWith("io.jooby.annotation.Trpc") + || annoName.equals("io.jooby.annotation.JsonRpc") + || annoName.startsWith("io.jooby.annotation.Mcp")) { + continue; + } + + if (HttpMethod.hasAnnotation(annoElement)) { + RestRoute route = new RestRoute(router, method, annoElement); + String uniqueKey = method.toString() + annoElement.getSimpleName(); + router.routes.putIfAbsent(uniqueKey, route); + } } } } @@ -46,7 +58,9 @@ public static RestRouter parse(MvcContext context, TypeElement controller) { var grouped = router.routes.values().stream().collect(Collectors.groupingBy(RestRoute::getMethodName)); for (var overloads : grouped.values()) { - if (overloads.size() > 1) { + long distinctMethods = + overloads.stream().map(r -> r.getMethod().toString()).distinct().count(); + if (distinctMethods > 1) { for (var route : overloads) { var paramsString = route.getRawParameterTypes(true, false).stream() @@ -133,7 +147,9 @@ public String getSourceCode(Boolean generateKotlin) throws IOException { .append(System.lineSeparator()) .append(System.lineSeparator()); + var generatedHandlers = new java.util.HashSet<>(); getRoutes().stream() + .filter(it -> generatedHandlers.add(it.getGeneratedName())) .flatMap(it -> it.generateHandlerCall(kt).stream()) .forEach(line -> buffer.append(CodeBlock.indent(4)).append(line)); diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRoute.java index d9d1a916d2..7c5d920b36 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRoute.java @@ -86,7 +86,7 @@ public List generateMapping(boolean kt, String routerName) { ? "/** See [" + routerName + "." + getMethodName() + "] */" : "/** See {@link " + routerName + "#" + getMethodName() + "} */")); block.add( - statement( + of( isSuspendFun() ? "" : "app.", dslMethod, "(", @@ -255,61 +255,263 @@ public List generateHandlerCall(boolean kt) { ")) {")); } - // Read parameters (re-using the logic concept from before) + // Read parameters optimally for (var parameter : parameters) { var paramenterName = parameter.getName(); - if (parameter.getType().getRawType().toString().equals("io.jooby.Context")) { + var rawType = parameter.getType().getRawType().toString(); + var type = type(kt, parameter.getType().toString()); + boolean isNullable = parameter.isNullable(kt); + + if (rawType.equals("io.jooby.Context")) { paramList.add("ctx"); continue; } - var type = type(kt, parameter.getType().toString()); - if (kt) { - buffer.add( - statement( - indent(4), - "val ", - paramenterName, - "Decoder: io.jooby.rpc.trpc.TrpcDecoder<", - type, - "> = parser.decoder(", - parameter.getType().toSourceCode(kt), - ")")); - buffer.add( - statement( - indent(4), - "val ", - paramenterName, - " = reader.nextObject(", - string(paramenterName), - ", ", - paramenterName, - "Decoder)")); - } else { - buffer.add( - statement( - indent(4), - "io.jooby.rpc.trpc.TrpcDecoder<", - type, - "> ", - paramenterName, - "Decoder = parser.decoder(", - parameter.getType().toSourceCode(kt), - ")", - semicolon(false))); - buffer.add( - statement( - indent(4), - type, - " ", - paramenterName, - " = reader.nextObject(", - string(paramenterName), - ", ", - paramenterName, - "Decoder)", - semicolon(false))); + if (rawType.equals("kotlin.coroutines.Continuation")) { + continue; + } + + switch (rawType) { + case "int": + case "long": + case "double": + case "boolean": + case "java.lang.String": + case "java.lang.Integer": + case "java.lang.Long": + case "java.lang.Double": + case "java.lang.Boolean": + var simpleType = type.startsWith("java.lang.") ? type.substring(10) : type; + if (simpleType.equals("Integer") || simpleType.equals("int")) simpleType = "Int"; + var readName = + "next" + Character.toUpperCase(simpleType.charAt(0)) + simpleType.substring(1); + + if (isNullable) { + if (kt) { + buffer.add( + statement( + indent(4), + "val ", + paramenterName, + " = if (reader.nextIsNull(", + string(paramenterName), + ")) null else reader.", + readName, + "(", + string(paramenterName), + ")")); + } else { + buffer.add( + statement( + indent(4), + var(kt), + paramenterName, + " = reader.nextIsNull(", + string(paramenterName), + ") ? null : reader.", + readName, + "(", + string(paramenterName), + ")", + semicolon(kt))); + } + } else { + buffer.add( + statement( + indent(4), + var(kt), + paramenterName, + " = reader.", + readName, + "(", + string(paramenterName), + ")", + semicolon(kt))); + } + paramList.add(paramenterName); + break; + + case "byte": + case "short": + case "float": + case "char": + case "java.lang.Byte": + case "java.lang.Short": + case "java.lang.Float": + case "java.lang.Character": + var isChar = type.equals("char") || type.equals("java.lang.Character"); + var isFloat = type.equals("float") || type.equals("java.lang.Float"); + var readMethod = isFloat ? "nextDouble" : (isChar ? "nextString" : "nextInt"); + + if (isNullable) { + if (kt) { + var ktCast = + isChar + ? "?.get(0)" + : "?.to" + + Character.toUpperCase(type.replace("java.lang.", "").charAt(0)) + + type.replace("java.lang.", "").substring(1) + + "()"; + buffer.add( + statement( + indent(4), + "val ", + paramenterName, + " = if (reader.nextIsNull(", + string(paramenterName), + ")) null else reader.", + readMethod, + "(", + string(paramenterName), + ")", + ktCast)); + } else { + var targetType = type.replace("java.lang.", ""); + var javaPrefix = isChar ? "" : "(" + targetType + ") "; + var javaSuffix = isChar ? ".charAt(0)" : ""; + buffer.add( + statement( + indent(4), + var(kt), + paramenterName, + " = reader.nextIsNull(", + string(paramenterName), + ") ? null : ", + javaPrefix, + "reader.", + readMethod, + "(", + string(paramenterName), + ")", + javaSuffix, + semicolon(kt))); + } + } else { + if (kt) { + var ktCast = + isChar + ? "[0]" + : ".to" + + Character.toUpperCase(type.replace("java.lang.", "").charAt(0)) + + type.replace("java.lang.", "").substring(1) + + "()"; + buffer.add( + statement( + indent(4), + var(kt), + paramenterName, + " = reader.", + readMethod, + "(", + string(paramenterName), + ")", + ktCast, + semicolon(kt))); + } else { + var targetType = type.replace("java.lang.", ""); + var javaPrefix = isChar ? "" : "(" + targetType + ") "; + var javaSuffix = isChar ? ".charAt(0)" : ""; + buffer.add( + statement( + indent(4), + var(kt), + paramenterName, + " = ", + javaPrefix, + "reader.", + readMethod, + "(", + string(paramenterName), + ")", + javaSuffix, + semicolon(kt))); + } + } + paramList.add(paramenterName); + break; + + default: + var genericType = kt ? type : box(type); // Box primitives for Java Generics + if (kt) { + buffer.add( + statement( + indent(4), + "val ", + paramenterName, + "Decoder: io.jooby.rpc.trpc.TrpcDecoder<", + type, + "> = parser.decoder(", + parameter.getType().toSourceCode(kt), + ")")); + if (isNullable) { + buffer.add( + statement( + indent(4), + "val ", + paramenterName, + " = if (reader.nextIsNull(", + string(paramenterName), + ")) null else reader.nextObject(", + string(paramenterName), + ", ", + paramenterName, + "Decoder)")); + } else { + buffer.add( + statement( + indent(4), + "val ", + paramenterName, + " = reader.nextObject(", + string(paramenterName), + ", ", + paramenterName, + "Decoder)")); + } + } else { + buffer.add( + statement( + indent(4), + "io.jooby.rpc.trpc.TrpcDecoder<", + genericType, + "> ", + paramenterName, + "Decoder = parser.decoder(", + parameter.getType().toSourceCode(kt), + ")", + semicolon(false))); + if (isNullable) { + buffer.add( + statement( + indent(4), + type, + " ", + paramenterName, + " = reader.nextIsNull(", + string(paramenterName), + ") ? null : reader.nextObject(", + string(paramenterName), + ", ", + paramenterName, + "Decoder)", + semicolon(false))); + } else { + buffer.add( + statement( + indent(4), + type, + " ", + paramenterName, + " = reader.nextObject(", + string(paramenterName), + ", ", + paramenterName, + "Decoder)", + semicolon(false))); + } + } + paramList.add(paramenterName); + break; } - paramList.add(paramenterName); } } @@ -338,4 +540,18 @@ public List generateHandlerCall(boolean kt) { buffer.add(statement("}", System.lineSeparator())); return buffer; } + + private String box(String type) { + return switch (type) { + case "int" -> "Integer"; + case "long" -> "Long"; + case "double" -> "Double"; + case "float" -> "Float"; + case "boolean" -> "Boolean"; + case "byte" -> "Byte"; + case "short" -> "Short"; + case "char" -> "Character"; + default -> type; + }; + } } diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRouter.java index bbd5746049..af0edbf978 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRouter.java @@ -22,16 +22,33 @@ public TrpcRouter(MvcContext context, TypeElement clazz) { public static TrpcRouter parse(MvcContext context, TypeElement controller) { var router = new TrpcRouter(context, controller); - for (var enclosed : controller.getEnclosedElements()) { - if (enclosed.getKind() == ElementKind.METHOD) { - ExecutableElement method = (ExecutableElement) enclosed; - if (AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.Trpc") != null - || AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.Trpc.Query") - != null - || AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.Trpc.Mutation") - != null) { - TrpcRoute route = new TrpcRoute(router, method); - router.routes.put(route.getMethodName(), route); + + // 1. Walk up the inheritance tree to catch tRPC methods in base classes + for (TypeElement type : context.superTypes(controller)) { + for (var enclosed : type.getEnclosedElements()) { + if (enclosed.getKind() == ElementKind.METHOD) { + ExecutableElement method = (ExecutableElement) enclosed; + + // Ignore abstract methods + if (method.getModifiers().contains(javax.lang.model.element.Modifier.ABSTRACT)) { + continue; + } + + if (AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.Trpc") != null + || AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.Trpc.Query") + != null + || AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.Trpc.Mutation") + != null) { + + TrpcRoute route = new TrpcRoute(router, method); + + // 2. Use the full string signature to prevent overloaded methods from clobbering each + // other + String uniqueKey = method.toString(); + + // 3. putIfAbsent ensures subclass overrides take priority over base class methods + router.routes.putIfAbsent(uniqueKey, route); + } } } } diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRouter.java index 24f4a9d123..5b7b5af74e 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRouter.java @@ -54,26 +54,26 @@ private void setup(java.util.function.Function f public static final String KOTLIN = """ package ${packageName} - ${imports} - @io.jooby.annotation.Generated(${className}::class) - open class ${generatedClassName} : ${implements} { - private lateinit var factory: java.util.function.Function - - ${constructors} - constructor(instance: ${className}) { setup { instance } } + ${imports} + @io.jooby.annotation.Generated(${className}::class) + open class ${generatedClassName} : ${implements} { + private lateinit var factory: java.util.function.Function + ${constructors} + constructor(instance: ${className}) { setup { instance } } - constructor(provider: io.jooby.SneakyThrows.Supplier<${className}>) { setup { provider.get() } } + constructor(provider: io.jooby.SneakyThrows.Supplier<${className}>) { setup { provider.get() } } - constructor(provider: (Class<${className}>) -> ${className}) { setup { provider(${className}::class.java) } } + constructor(provider: (Class<${className}>) -> ${className}) { setup { provider(${className}::class.java) } } - constructor(provider: io.jooby.SneakyThrows.Function, ${className}>) { setup { provider.apply(${className}::class.java) } } + constructor(provider: io.jooby.SneakyThrows.Function, ${className}>) { setup { provider.apply(${className}::class.java) } } - private fun setup(factory: java.util.function.Function) { - this.factory = factory - } - ${methods} + private fun setup(factory: java.util.function.Function) { + this.factory = factory } + ${methods} + } + """; protected final MvcContext context; diff --git a/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java b/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java index b8eb3f8304..6770182f7d 100644 --- a/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java +++ b/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java @@ -14,6 +14,7 @@ import java.nio.file.Paths; import java.util.*; import java.util.function.Consumer; +import java.util.function.Predicate; import java.util.stream.Stream; import javax.tools.JavaFileObject; @@ -173,11 +174,42 @@ public ProcessorRunner withSourceCode(SneakyThrows.Consumer consumer) { return withSourceCode(false, consumer); } + public ProcessorRunner withMcpCode(SneakyThrows.Consumer consumer) { + return withSourceCode(false, it -> it.endsWith("Mcp_"), consumer); + } + + public ProcessorRunner withTrpcCode(SneakyThrows.Consumer consumer) { + return withSourceCode(false, it -> it.endsWith("Trpc_"), consumer); + } + + public ProcessorRunner withRpcCode(SneakyThrows.Consumer consumer) { + return withSourceCode(false, it -> it.endsWith("Rpc_"), consumer); + } + public ProcessorRunner withSourceCode(boolean kt, SneakyThrows.Consumer consumer) { consumer.accept( kt ? processor.kotlinFiles.values().iterator().next() : Optional.ofNullable(processor.getSource()).map(Objects::toString).orElse(null)); + return withSourceCode( + kt, it -> !it.endsWith("Trpc_") && !it.endsWith("Rpc_") && !it.endsWith("Mcp_"), consumer); + } + + private ProcessorRunner withSourceCode( + boolean kt, Predicate filter, SneakyThrows.Consumer consumer) { + consumer.accept( + kt + ? processor.kotlinFiles.entrySet().stream() + .filter(it -> filter.test(it.getKey())) + .map(Map.Entry::getValue) + .findFirst() + .orElse(null) + : processor.javaFiles.entrySet().stream() + .filter(it -> filter.test(it.getKey())) + .map(Map.Entry::getValue) + .map(Objects::toString) + .findFirst() + .orElse(null)); return this; } diff --git a/modules/jooby-apt/src/test/java/tests/HandlerCompilerTest.java b/modules/jooby-apt/src/test/java/tests/HandlerCompilerTest.java index 04e597fd09..a5c773a396 100644 --- a/modules/jooby-apt/src/test/java/tests/HandlerCompilerTest.java +++ b/modules/jooby-apt/src/test/java/tests/HandlerCompilerTest.java @@ -534,7 +534,6 @@ public void body() throws Exception { @Test public void jarxs() throws Exception { new ProcessorRunner(new JaxrsController()) - .withSourceCode(System.out::println) .withRouter( app -> { MockRouter router = new MockRouter(app); diff --git a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java index 3d882986dc..cdf3da22d6 100644 --- a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java +++ b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java @@ -15,7 +15,7 @@ public class Issue3830 { @Test public void shouldGenerateMcpServer() throws Exception { new ProcessorRunner(new ExampleServer()) - .withSourceCode( + .withMcpCode( source -> { assertThat(source) .isEqualToNormalizingWhitespace( diff --git a/modules/jooby-apt/src/test/java/tests/i3853/C3853.java b/modules/jooby-apt/src/test/java/tests/i3853/C3853.java index 51ecf302c8..2454aaa0ba 100644 --- a/modules/jooby-apt/src/test/java/tests/i3853/C3853.java +++ b/modules/jooby-apt/src/test/java/tests/i3853/C3853.java @@ -20,26 +20,26 @@ public U3853 projectUser() { return new U3853(1, "Projected User", "Projected", "User"); } - @GET("/optinal") + @GET("/find-user-opt") @Project("(id, name)") public Optional findUser() { return Optional.of(new U3853(1, "Projected User", "Projected", "User")); } - @GET("/list") + @GET("/find-users") @Project("(id, name)") public List findUsers() { return List.of(new U3853(1, "Projected User", "Projected", "User")); } - @GET("/list") + @GET("/projected") @Project("(id, name)") public Projected projected() { return Projected.wrap(new U3853(1, "Projected User", "Projected", "User")) .include("(id, name)"); } - @GET(value = "/list", projection = "(id, name)") + @GET(value = "/projected-projection", projection = "(id, name)") public Projected projectedProjection() { return Projected.wrap(new U3853(1, "Projected User", "Projected", "User")) .include("(id, name)"); diff --git a/modules/jooby-apt/src/test/java/tests/i3863/Issue3863.java b/modules/jooby-apt/src/test/java/tests/i3863/Issue3863.java index 6c333fdd6e..c16c70bcdc 100644 --- a/modules/jooby-apt/src/test/java/tests/i3863/Issue3863.java +++ b/modules/jooby-apt/src/test/java/tests/i3863/Issue3863.java @@ -21,7 +21,7 @@ public void trpcOnTopLevelDoesNothingByItSelf() throws Exception { @Test public void trpcQuery() throws Exception { new ProcessorRunner(new SpecificTrpcAnnotation()) - .withSourceCode( + .withTrpcCode( source -> { assertThat(source) .contains("app.get(\"/trpc/users.getUserById\", this::trpcGetUserById);") @@ -32,12 +32,16 @@ public void trpcQuery() throws Exception { @Test public void mixedAnnotation() throws Exception { new ProcessorRunner(new MixedTrpcAnnotation()) - .withSourceCode( + .withTrpcCode( source -> { assertThat(source) // tRPC .contains("app.get(\"/trpc/users.getUserById\", this::trpcGetUserById);") - .contains("app.post(\"/trpc/users.createUser\", this::trpcCreateUser);") + .contains("app.post(\"/trpc/users.createUser\", this::trpcCreateUser);"); + }) + .withSourceCode( + source -> { + assertThat(source) // REST .contains("app.get(\"/api/users/{id}\", this::getUserById);") .contains("app.post(\"/api/users\", this::createUser);"); @@ -47,14 +51,18 @@ public void mixedAnnotation() throws Exception { @Test public void mixedMutation() throws Exception { new ProcessorRunner(new MixedMutation()) - .withSourceCode( + .withTrpcCode( source -> { assertThat(source) // tRPC .contains("app.post(\"/trpc/users.createUser\", this::trpcCreateUser);") .contains("app.post(\"/trpc/users.updateUser\", this::trpcUpdateUser);") .contains("app.post(\"/trpc/users.patchUser\", this::trpcPatchUser);") - .contains("app.post(\"/trpc/users.deleteUser\", this::trpcDeleteUser);") + .contains("app.post(\"/trpc/users.deleteUser\", this::trpcDeleteUser);"); + }) + .withSourceCode( + source -> { + assertThat(source) // REST .contains("app.post(\"/\", this::createUser);") .contains("app.put(\"/\", this::updateUser);") @@ -66,12 +74,12 @@ public void mixedMutation() throws Exception { @Test public void overloadTrpc() throws Exception { new ProcessorRunner(new OverloadTrpc()) - .withSourceCode( + .withTrpcCode( source -> { assertThat(source) // tRPC .contains("app.get(\"/trpc/users.ping\", this::trpcPing);") - .contains("app.get(\"/trpc/users.ping\", this::trpcPingInteger);"); + .contains("app.get(\"/trpc/users.ping.since\", this::trpcPingInteger);"); }); } } diff --git a/modules/jooby-apt/src/test/java/tests/i3863/OverloadTrpc.java b/modules/jooby-apt/src/test/java/tests/i3863/OverloadTrpc.java index e6a7ed4447..d8449da16c 100644 --- a/modules/jooby-apt/src/test/java/tests/i3863/OverloadTrpc.java +++ b/modules/jooby-apt/src/test/java/tests/i3863/OverloadTrpc.java @@ -15,7 +15,7 @@ public String ping() { return null; } - @Trpc.Query + @Trpc.Query("ping.since") public String ping(Integer since) { return null; } diff --git a/modules/jooby-apt/src/test/java/tests/i3864/Issue3868.java b/modules/jooby-apt/src/test/java/tests/i3864/Issue3868.java index 7c259c42c8..8c97f4ab48 100644 --- a/modules/jooby-apt/src/test/java/tests/i3864/Issue3868.java +++ b/modules/jooby-apt/src/test/java/tests/i3864/Issue3868.java @@ -15,7 +15,7 @@ public class Issue3868 { @Test public void topLevelAnnotationMakeAllPublicJSONRPC() throws Exception { new ProcessorRunner(new DefaultMapping()) - .withSourceCode( + .withRpcCode( source -> { assertThat(source) .contains( @@ -33,7 +33,7 @@ public void topLevelAnnotationMakeAllPublicJSONRPC() throws Exception { @Test public void emptyNamespaceMustGenerateMethodNameOnly() throws Exception { new ProcessorRunner(new EmptyNamespace()) - .withSourceCode( + .withRpcCode( source -> { assertThat(source) .contains("return java.util.List.of(\"rpcMethod1\", \"rpcMethod2\")"); @@ -43,7 +43,7 @@ public void emptyNamespaceMustGenerateMethodNameOnly() throws Exception { @Test public void explicitMappingTurnOffDefaultMapping() throws Exception { new ProcessorRunner(new ExplicitMapping()) - .withSourceCode( + .withRpcCode( source -> { assertThat(source).contains("return java.util.List.of(\"explicit.onlyThis\")"); }); @@ -52,7 +52,7 @@ public void explicitMappingTurnOffDefaultMapping() throws Exception { @Test public void customNaming() throws Exception { new ProcessorRunner(new CustomNaming()) - .withSourceCode( + .withRpcCode( source -> { assertThat(source) .contains("return java.util.List.of(\"movies.getById\", \"movies.create\")"); @@ -62,7 +62,7 @@ public void customNaming() throws Exception { @Test public void shouldInjectContext() throws Exception { new ProcessorRunner(new WithContext()) - .withSourceCode( + .withRpcCode( source -> { assertThat(source).contains("return c.rpcMethod1(ctx, value);"); }); @@ -71,7 +71,7 @@ public void shouldInjectContext() throws Exception { @Test public void shouldFollowNullability() throws Exception { new ProcessorRunner(new NullSupport()) - .withSourceCode( + .withRpcCode( source -> { assertThat(source) .contains( @@ -91,7 +91,7 @@ public void shouldFollowNullability() throws Exception { @Test public void shouldGenerateDefaultConstructorForDI() throws Exception { new ProcessorRunner(new DIService(null)) - .withSourceCode( + .withRpcCode( source -> { assertThat(source) .containsIgnoringWhitespaces( From dd8ea41c1d7808da95c161adb3a58ad46c9ed159 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Wed, 25 Mar 2026 19:55:27 -0300 Subject: [PATCH 07/37] jooby-mcp: Add kliushnichenko mcp runtime --- .../io/jooby/annotation/McpCompletion.java | 3 - .../io/jooby/jackson3/Jackson3Module.java | 2 +- modules/jooby-mcp/pom.xml | 8 + modules/jooby-mcp/src/ExampleMcpServer.java | 214 ++++++++ modules/jooby-mcp/src/ToolsExample.java | 46 ++ modules/jooby-mcp/src/WeatherServer.java | 22 + modules/jooby-mcp/src/WeatherService.java | 13 + .../internal/mcp/BaseMcpServerRunner.java | 123 +++++ .../internal/mcp/McpCompletionHandler.java | 74 +++ .../jooby/internal/mcp/McpPromptHandler.java | 96 ++++ .../internal/mcp/McpResourceHandler.java | 84 +++ .../mcp/McpResourceTemplateHandler.java | 57 +++ .../jooby/internal/mcp/McpServerConfig.java | 187 +++++++ .../mcp/McpStatelessServerRunner.java | 142 +++++ .../internal/mcp/McpSyncServerRunner.java | 166 ++++++ .../io/jooby/internal/mcp/McpToolHandler.java | 100 ++++ .../io/jooby/internal/mcp/MethodInvoker.java | 25 + .../java/io/jooby/internal/mcp/ToolSpec.java | 121 +++++ .../java/io/jooby/mcp/JoobyMcpServer.java | 45 ++ .../src/main/java/io/jooby/mcp/McpModule.java | 171 +++++++ .../src/main/java/io/jooby/mcp/McpResult.java | 30 ++ .../main/java/io/jooby/mcp/McpService.java | 36 +- .../main/java/io/jooby/mcp/ResourceUri.java | 13 + .../transport/JoobySseTransportProvider.java | 213 ++++++++ .../JoobyStatelessServerTransport.java | 127 +++++ ...oobyStreamableServerTransportProvider.java | 484 ++++++++++++++++++ .../io/jooby/mcp/transport/SendError.java | 136 +++++ .../mcp/transport/TransportConstants.java | 18 + .../java/io/jooby/mcp/WeatherMcpServer.java | 177 +++++++ .../test/java/io/jooby/mcp/WeatherServer.java | 12 + 30 files changed, 2916 insertions(+), 29 deletions(-) create mode 100644 modules/jooby-mcp/src/ExampleMcpServer.java create mode 100644 modules/jooby-mcp/src/ToolsExample.java create mode 100644 modules/jooby-mcp/src/WeatherServer.java create mode 100644 modules/jooby-mcp/src/WeatherService.java create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/BaseMcpServerRunner.java create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpCompletionHandler.java create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpPromptHandler.java create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpResourceHandler.java create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpResourceTemplateHandler.java create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpServerConfig.java create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpStatelessServerRunner.java create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpSyncServerRunner.java create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpToolHandler.java create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/MethodInvoker.java create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/ToolSpec.java create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/mcp/JoobyMcpServer.java create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/mcp/McpResult.java create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/mcp/ResourceUri.java create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobySseTransportProvider.java create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobyStatelessServerTransport.java create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobyStreamableServerTransportProvider.java create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/SendError.java create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/TransportConstants.java create mode 100644 modules/jooby-mcp/src/test/java/io/jooby/mcp/WeatherMcpServer.java create mode 100644 modules/jooby-mcp/src/test/java/io/jooby/mcp/WeatherServer.java diff --git a/jooby/src/main/java/io/jooby/annotation/McpCompletion.java b/jooby/src/main/java/io/jooby/annotation/McpCompletion.java index 06acf00532..54fb7f6f29 100644 --- a/jooby/src/main/java/io/jooby/annotation/McpCompletion.java +++ b/jooby/src/main/java/io/jooby/annotation/McpCompletion.java @@ -19,7 +19,4 @@ * Resource Template URI (e.g., "file:///project/{name}"). */ String ref(); - - /** The name of the argument or template variable being completed. */ - String arg(); } diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/Jackson3Module.java b/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/Jackson3Module.java index d18a131c4a..511d1725d9 100644 --- a/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/Jackson3Module.java +++ b/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/Jackson3Module.java @@ -236,7 +236,7 @@ public Object decode(Context ctx, Type type) throws Exception { * @param modules Extra/additional modules to install. * @return Object mapper instance. */ - public static ObjectMapper create(JacksonModule... modules) { + public static JsonMapper create(JacksonModule... modules) { JsonMapper.Builder builder = JsonMapper.builder(); Stream.of(modules).forEach(builder::addModule); diff --git a/modules/jooby-mcp/pom.xml b/modules/jooby-mcp/pom.xml index e169662125..4f65a7a135 100644 --- a/modules/jooby-mcp/pom.xml +++ b/modules/jooby-mcp/pom.xml @@ -18,9 +18,17 @@ jooby ${jooby.version} + + com.fasterxml.jackson.core + jackson-databind + io.modelcontextprotocol.sdk mcp-core + + io.modelcontextprotocol.sdk + mcp-json-jackson2 + diff --git a/modules/jooby-mcp/src/ExampleMcpServer.java b/modules/jooby-mcp/src/ExampleMcpServer.java new file mode 100644 index 0000000000..a042c5a194 --- /dev/null +++ b/modules/jooby-mcp/src/ExampleMcpServer.java @@ -0,0 +1,214 @@ +// This file is generated by McpToolProcessor. Do not modify manually. +package io.github.kliushnichenko.jooby.mcp.example; + +import io.github.kliushnichenko.jooby.mcp.JoobyMcpServer; +import io.github.kliushnichenko.jooby.mcp.ResourceUri; +import io.github.kliushnichenko.jooby.mcp.internal.MethodInvoker; +import io.github.kliushnichenko.jooby.mcp.internal.ToolSpec; +import io.jooby.Jooby; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpSchema; +import java.lang.Object; +import java.lang.String; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Generated Jooby MCP Server. Do not modify manually. + */ +public class ExampleMcpServer implements JoobyMcpServer { + private Jooby app; + + private McpJsonMapper mcpJsonMapper; + + /** + * Map of tool names to its specification. + */ + private final Map tools = new HashMap<>(); + + /** + * Map of tool names to method invokers. + */ + private final Map toolInvokers = new HashMap<>(); + + /** + * Map of prompt names to its specification. + */ + private final Map prompts = new HashMap<>(); + + /** + * Map of prompt names to method invokers. + */ + private final Map promptInvokers = new HashMap<>(); + + /** + * List of completions reference objects. + */ + private final List completions = new ArrayList<>(); + + /** + * Map of completion key(a composition of _) to method invoker. + */ + private final Map> completionInvokers = new HashMap<>(); + + /** + * List of resources. + */ + private final List resources = new ArrayList<>(); + + /** + * Map of resource URI to method invoker. + */ + private final Map> resourceReaders = new HashMap<>(); + + /** + * List of resource templates. + */ + private final List resourceTemplates = new ArrayList<>(); + + /** + * Map of resource URI template to method invoker. + */ + private final Map, Object>> resourceTemplateReaders = new HashMap<>(); + + /** + * Initialize a new server. + * @param app the Jooby application instance + * @param mcpJsonMapper json serializer instance + */ + public void init(final Jooby app, final McpJsonMapper mcpJsonMapper) { + this.app = app; + this.mcpJsonMapper = mcpJsonMapper; + + tools.put("elicitation_example", ToolSpec.builder().name("elicitation_example").description("Request the username over elicitation").inputSchema("{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}").requiredArguments(List.of()).build()); + tools.put("add", ToolSpec.builder().name("add").description("Adds two numbers together").inputSchema("{\"type\":\"object\",\"properties\":{\"first\":{\"type\":\"integer\",\"description\":\"First number to add\"},\"second\":{\"type\":\"integer\",\"description\":\"Second number to add\"}},\"required\":[\"first\",\"second\"],\"additionalProperties\":false}").outputSchema("{\"type\":\"object\",\"properties\":{\"operation\":{\"type\":\"string\"},\"result\":{\"type\":\"number\"},\"expression\":{\"type\":\"string\"}},\"required\":[\"operation\",\"result\",\"expression\"],\"additionalProperties\":false}").requiredArguments(List.of("first", "second")).build()); + tools.put("subtract", ToolSpec.builder().name("subtract").inputSchema("{\"type\":\"object\",\"properties\":{\"a\":{\"type\":\"integer\"},\"b\":{\"type\":\"integer\"}},\"required\":[\"a\",\"b\"],\"additionalProperties\":false}").requiredArguments(List.of("a", "b")).build()); + tools.put("pi_sign_image", ToolSpec.builder().name("pi_sign_image").description("Returns an image of the Pi").inputSchema("{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}").requiredArguments(List.of()).build()); + tools.put("get_client_info", ToolSpec.builder().name("get_client_info").description("Returns the information about the client initiated the request").inputSchema("{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}").requiredArguments(List.of()).build()); + + toolInvokers.put("elicitation_example", (args, exchange) -> app.require(ElicitationExample.class).requestUsername(exchange)); + toolInvokers.put("add", (args, exchange) -> app.require(ToolsExample.class).add((int) args.get("first"), (int) args.get("second"))); + toolInvokers.put("subtract", (args, exchange) -> app.require(ToolsExample.class).subtract((int) args.get("a"), (int) args.get("b"), exchange)); + toolInvokers.put("pi_sign_image", (args, exchange) -> app.require(ToolsExample.class).getPiSignImage()); + toolInvokers.put("get_client_info", (args, exchange) -> app.require(ToolsExample.class).getClientInfo(exchange)); + + prompts.put("summarizeText", new McpSchema.Prompt("summarizeText", "", "Summarizes the provided text into a specified number of sentences", List.of(new McpSchema.PromptArgument("text_to_summarize", null, true), new McpSchema.PromptArgument("maxSentences", null, true)))); + prompts.put("code_review", new McpSchema.Prompt("code_review", "", "Code Review Prompt", List.of(new McpSchema.PromptArgument("codeSnippet", null, true), new McpSchema.PromptArgument("language", null, true), new McpSchema.PromptArgument("scrutinyLevel", null, true)))); + + promptInvokers.put("summarizeText", (args, exchange) -> app.require(PromptsExample.class).summarizeText((String) args.get("text_to_summarize"), (String) args.get("maxSentences"))); + promptInvokers.put("code_review", (args, exchange) -> app.require(PromptsExample.class).codeReviewPrompt((String) args.get("codeSnippet"), (String) args.get("language"), (String) args.get("scrutinyLevel"))); + completions.add(new McpSchema.PromptReference("code_review")); + completions.add(new McpSchema.PromptReference("code_review")); + completions.add(new McpSchema.ResourceReference("file:///project/{name}")); + + completionInvokers.put("code_review_language", (input) -> app.require(PromptsExample.class).completeCodeReviewLang(input)); + completionInvokers.put("code_review_scrutinyLevel", (input) -> app.require(PromptsExample.class).completeScrutinyLevel(input)); + completionInvokers.put("file:///project/{name}_name", (input) -> app.require(ResourceTemplateExamples.class).projectNameCompletion(input)); + + resources.add(McpSchema.Resource.builder().name("README.md").title("README.md").uri("file:///project/README.md").mimeType("text/markdown").build()); + resources.add(McpSchema.Resource.builder().name("blobResource").uri("file:///blob").build()); + resources.add(McpSchema.Resource.builder().name("threadStone").uri("file:///project/thread-stone").size(Long.valueOf(10563)).annotations(new McpSchema.Annotations(List.of(McpSchema.Role.USER, McpSchema.Role.ASSISTANT), 0.3, null)).build()); + resources.add(McpSchema.Resource.builder().name("blackBriar").uri("file:///project/blackbriar/metadata.json").build()); + + resourceReaders.put("file:///project/README.md", () -> app.require(ResourceExamples.class).textResource()); + resourceReaders.put("file:///blob", () -> app.require(ResourceExamples.class).blobResource()); + resourceReaders.put("file:///project/thread-stone", () -> app.require(ResourceExamples.class).threadStone()); + resourceReaders.put("file:///project/blackbriar/metadata.json", () -> app.require(ResourceExamples.class).blackBriar()); + + resourceTemplates.add(McpSchema.ResourceTemplate.builder().name("get_project").uriTemplate("file:///project/{name}").build()); + + resourceTemplateReaders.put("file:///project/{name}", (args) -> app.require(ResourceTemplateExamples.class).getProject((String) args.get("name"), new ResourceUri((String) args.get("__resourceUri")))); + + } + + /** + * Invokes a tool by name with the provided arguments. + * @param toolName the name of the tool to invoke + * @param args the arguments to pass to the tool + * @return the result of the tool invocation + */ + public Object invokeTool(final String toolName, final Map args, + final McpSyncServerExchange exchange) { + MethodInvoker invoker = toolInvokers.get(toolName); + return invoker.invoke(args, exchange); + } + + /** + * Invokes a prompt by name with the provided arguments. + * @param promptName the name of the prompt to invoke + * @param args the arguments to pass to the prompt + * @return the result of the prompt invocation + */ + public Object invokePrompt(final String promptName, final Map args, + final McpSyncServerExchange exchange) { + MethodInvoker invoker = promptInvokers.get(promptName); + return invoker.invoke(args, exchange); + } + + /** + * Invokes a completion by identifier(prompt or resource name) and argumentName with the provided + * argument value. + * @param identifier prompt or resource template name + * @param argumentName the name of an argument in prompt or resource template + * @param input incoming argument value + * @return the result of the completion invocation + */ + public Object invokeCompletion(final String identifier, final String argumentName, + final String input) { + var completionKey = identifier + '_' + argumentName; + var invoker = completionInvokers.get(completionKey); + if (invoker == null) { + return List.of(); + } + return invoker.apply(input); + } + + /** + * Reads a resource by URI + * @param uri Resource URI + * @return resource content + */ + public Object readResource(final String uri) { + var reader = resourceReaders.get(uri); + return reader.get(); + } + + /** + * Reads a resource by URI according to template + * @param uriTemplate Resource URI template + * @return resource content + */ + public Object readResourceByTemplate(final String uriTemplate, final Map args) { + var reader = resourceTemplateReaders.get(uriTemplate); + return reader.apply(args); + } + + public Map getTools() { + return tools; + } + + public Map getPrompts() { + return prompts; + } + + public List getCompletions() { + return completions; + } + + public List getResources() { + return resources; + } + + public List getResourceTemplates() { + return resourceTemplates; + } + + public String getServerKey() { + return "example"; + } +} diff --git a/modules/jooby-mcp/src/ToolsExample.java b/modules/jooby-mcp/src/ToolsExample.java new file mode 100644 index 0000000000..11b2b1f790 --- /dev/null +++ b/modules/jooby-mcp/src/ToolsExample.java @@ -0,0 +1,46 @@ +package io.github.kliushnichenko.jooby.mcp.example; + +import io.github.kliushnichenko.jooby.mcp.annotation.OutputSchema; +import io.github.kliushnichenko.jooby.mcp.annotation.Tool; +import io.github.kliushnichenko.jooby.mcp.annotation.ToolArg; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpSchema; +import jakarta.inject.Singleton; + +/** + * @author kliushnichenko + */ +@Singleton +public class ToolsExample { + + private static final String PI_SIGN_IMAGE = "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAB2AAAAdgB+lymcgAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAagSURBVHic5ZtrbBVFFMd/3VuLUNsGCxRQE7W0osXECALy0ERjTBSJb9FPvhElmojwRT9ojBAfqFFjRAwmisZoEEQhEURjfFEeRiiVp8EPBqVVWmhVENr64eyms7Ovuffu7t3qP5lk994zZ87szpw5ry0jedQB04HzgUagAagFaoBTbJpuoBM4BOyxWwvwFdCWgoyxYyLwIvAj0FdkawVeAC5KdQYFoBpYAOym+EkHtd32GNVxCV0WA49aYB5wP7Kso3AMOAB0AX/av1UCVcBpQIUBj07gVWAxsm1KAgu4F/id4Dd2HNgILAJmAPVALoRnzqaZYfdpBk6E8G8H7rZlSRUNyMSCBGsG5iKro1gMs3k1h4z3HTAmhrGMcAPQESDIemBygmNfALwP9PqMfQS4NcGxsRDNHvQGxic5uIYJBK+IxSSwJSqAd30G60L0QOp70B7zPsSG0OV6BzNlaoQKYI3PINuAc+IapAicixhNunxriOEhWPi/+RXA4GKZx4ghwEr8V0JRq9Nvzy8h/CgrFXLAUvx1QkG42YfZkqLFTBZlwOt45b4tX0ZjgMMak1VAeVySJogc8AFeZd1oysDCe8RsI1t7PgpD8CrGbzHUB7O1jt2Iph1oaESMI3Uud+pEujNUi3hcqgk7G9lXUcgB04CpwEhgUN4i54/fEFe5M+D/OYjT5KAdObo7ghguwv3ENhG9bCqAB21hknKDw9p7IbJZeH2WJ4OIq5En6RD2AhdGTH6UzwBpt+8jZJyI23c4hLjeHizQGH8UwXgYsK/Ek/8buDFCToBPtH7znT9UHbALt2k7FdGcQfgMuNzntzeB7cBfyu9fAqfb178AlwbwNKVz8CvyEKIwGXHYHOxFOxYn4X5CzREMr9XojwN3hNDvV2j3x0BXCLbglnk89Cs43Y9+K4LZPO1+PvLms4y3tXuXdahGb48j+zsIw3GHqfYQ7RtkYQWMwC13C8gKGAGMVQi3InG+IDThnvBKoCdOSRNCG/CDct8EjLCAS3Arwy8iGNVp9/uKly01bFCuy4DpFpKxUfF1BJNW+t94D+EnRdagz21cOV4vaWcEkx3A1cBVwFrkgQwU7NLuG8B9PBwlmWBHFpQgiDt/TBljk4Vb4x9gYCi0QnECMZ4cDLdw28Vd6cpTEnQr11UW/Slq/c//Ko4o11WliOdnChbakiiVIClCTa13Wbj3/f/hAahbvstCwkQORpHNmH9cKEfm6KDdQnxjB4OAM9OUKGWcjTtdtsdCvDkVAyUCfJnd8sFY7X6vhZi2KqYWLFIwTlauj8XAbyHi2GwAnsqj3zTtfgeIO6wGDTfGIKCKGsS6dPh/HkJragqrhVi6fR+GrUq/Xmx3uA23AzSB8IBIvrged2h9ewhtn3IdZqOoK8o0BV6HVJg4aAXanEHWKX/kgFmGTKNQCzyu/bYqhF6t+BqNpLh0VCLVZA7+MJRlFu6H+qn650TyC4qaYCTenMEmwkvzlmn0j/rQPKbRvGEoj7r8+/DJeezSCKYYMlZxBhLKXog7yeLEGqMU7BVanx7gJVuWKcDLuPVJH97QvB8ma318Yx56YmS1AWMHg5HIUFgSY64hr3URfNS21pCnXuLziB9RNe7yt15EIZpAzxOo7SjhOQMdwzCrMW7FrA5xEoapMZAzVR1kM2Y59XF4Kzp7gA8prJCqBskz6Mvd4bsMs7Jcv1qHJ1QCXSGdipyx6jE4B3jNYLArgZnIKvoJWZ4HDfqF4SzgGqRipQ+JQH8M/GzY/wHgFeW+DXkhQel0AO7B/cS6gfPyEDoraEKKsdW53G7S0UISiWrHFvzP5KyiEtER6hy+IY/q+Hq8x9hqBk6R1ArcsncinmBeuAWvAlpKPN8YJIUyxDDS5b6pUIbP+zBbSjaDJuX4T/7ZYphaSLmpznQl2dIJlUhFiy7ncmJYsUHF0i1kI3jShFfh9SEynxTXIBX4r4Ru5FuhUoTXc8g5rx91zpuPbfIOLKTw2M8s3Yx4lGlhEt6SF3XPJ6qor0Psab/B1wMXJzj2FMQS9Ptk5jDxxTEiUU+497cFeAgJtxWLOptX0Bt3jJy8z/liYQF3IfZ1kGAnkO3xNOInNBBuTJXbNDOBZ5BJ+zlETjuIeJoFL/k49spQ4GFEIQ01oP+H/g8nnbRcFZKxGY1ZjK8DCY6E1QmnjiqkXE6PLMXZdiLBjMyn8CYgJ8YO/BWWaetF7I3nSOizvDTs+uFIJdo43J/PD0UsOJCzvAOJ8O7F/fl8OwniX/VNNP8m9q82AAAAAElFTkSuQmCC"; + + public record ArithmeticResult(String operation, double result, String expression) { + } + + @Tool(name = "add", description = "Adds two numbers together") + public ArithmeticResult add( + @ToolArg(name = "first", description = "First number to add") int a, + @ToolArg(name = "second", description = "Second number to add") int b + ) { + int result = a + b; + return new ArithmeticResult("addition", result, a + " + " + b + " = " + result); + } + + @Tool + public String subtract(int a, int b, McpSyncServerExchange exchange) { + int result = a - b; + return String.valueOf(result); + } + + @Tool(name = "pi_sign_image", description = "Returns an image of the Pi") + public McpSchema.ImageContent getPiSignImage() { + return new McpSchema.ImageContent(null, PI_SIGN_IMAGE, "image/png"); + } + + @Tool(name = "get_client_info", description = "Returns the information about the client initiated the request") + @OutputSchema.Suppressed + public McpSchema.Implementation getClientInfo(McpSyncServerExchange exchange) { + return exchange.getClientInfo(); + } +} diff --git a/modules/jooby-mcp/src/WeatherServer.java b/modules/jooby-mcp/src/WeatherServer.java new file mode 100644 index 0000000000..1b1047bcbe --- /dev/null +++ b/modules/jooby-mcp/src/WeatherServer.java @@ -0,0 +1,22 @@ +package io.github.kliushnichenko.jooby.mcp.example; + +import io.github.kliushnichenko.jooby.mcp.annotation.McpServer; +import io.github.kliushnichenko.jooby.mcp.annotation.Tool; +import jakarta.inject.Singleton; +import lombok.RequiredArgsConstructor; + +/** + * @author kliushnichenko + */ +@Singleton +@McpServer("weather") +@RequiredArgsConstructor +public class WeatherServer { + + private final WeatherService weatherService; + + @Tool(name = "get_weather") + public String getWeather(double latitude, double longitude) { + return weatherService.getWeather(latitude, longitude); + } +} diff --git a/modules/jooby-mcp/src/WeatherService.java b/modules/jooby-mcp/src/WeatherService.java new file mode 100644 index 0000000000..371d67354c --- /dev/null +++ b/modules/jooby-mcp/src/WeatherService.java @@ -0,0 +1,13 @@ +package io.github.kliushnichenko.jooby.mcp.example; + +import jakarta.inject.Singleton; + +@Singleton +public class WeatherService { + + public String getWeather(double latitude, double longitude) { + // Simulate fetching weather data for the given location + // In a real application, this would involve calling a weather API + return "The weather in Numenor is sunny with a temperature of 25°C."; + } +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/BaseMcpServerRunner.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/BaseMcpServerRunner.java new file mode 100644 index 0000000000..4a36b25883 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/BaseMcpServerRunner.java @@ -0,0 +1,123 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.mcp; + +import java.util.Map; + +import io.jooby.Context; +import io.jooby.Jooby; +import io.jooby.mcp.JoobyMcpServer; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.server.McpTransportContextExtractor; +import io.modelcontextprotocol.spec.McpSchema; + +public abstract class BaseMcpServerRunner { + + protected static final McpTransportContextExtractor CTX_EXTRACTOR = + ctx -> { + var transportContext = Map.of("HEADERS", ctx.headerMap()); + return McpTransportContext.create(transportContext); + }; + + protected final Jooby app; + protected final JoobyMcpServer joobyMcpServer; + protected final McpServerConfig serverConfig; + protected final McpJsonMapper mcpJsonMapper; + protected final boolean isSingleServer; + + protected final McpToolHandler toolHandler; + protected final McpResourceHandler resourceHandler; + protected final McpResourceTemplateHandler resourceTemplateHandler; + + public BaseMcpServerRunner( + Jooby app, + JoobyMcpServer joobyMcpServer, + McpServerConfig serverConfig, + McpJsonMapper mcpJsonMapper, + boolean isSingleServer) { + this.app = app; + this.joobyMcpServer = joobyMcpServer; + this.serverConfig = serverConfig; + this.mcpJsonMapper = mcpJsonMapper; + this.isSingleServer = isSingleServer; + + this.toolHandler = new McpToolHandler(mcpJsonMapper); + this.resourceHandler = new McpResourceHandler(mcpJsonMapper); + this.resourceTemplateHandler = new McpResourceTemplateHandler(mcpJsonMapper); + } + + public void run() { + S mcpServer = initMcpServer(); + + initTools(mcpServer); + initPrompts(mcpServer); + initResources(mcpServer); + initResourceTemplates(mcpServer); + + addToJoobyRegistry(mcpServer); + logMcpStart(mcpServer); + app.onStop(() -> close(mcpServer)); + } + + protected abstract S initMcpServer(); + + protected abstract void initTools(S mcpServer); + + protected abstract void initPrompts(S mcpServer); + + protected abstract void initResources(S mcpServer); + + protected abstract void initResourceTemplates(S mcpServer); + + protected abstract void logMcpStart(S mcpServer); + + protected abstract void addToJoobyRegistry(S mcpServer); + + protected abstract void close(S mcpServer); + + protected McpSchema.Tool buildTool(ToolSpec toolSpec) { + McpSchema.Tool.Builder toolBuilder = + McpSchema.Tool.builder() + .name(toolSpec.getName()) + .title(toolSpec.getTitle()) + .description(toolSpec.getDescription()) + .inputSchema(mcpJsonMapper, toolSpec.getInputSchema()); + + if (toolSpec.getOutputSchema() != null) { + toolBuilder.outputSchema(mcpJsonMapper, toolSpec.getOutputSchema()); + } + + if (toolSpec.getAnnotations() != null) { + toolBuilder.annotations(toolSpec.getAnnotations()); + } + + return toolBuilder.build(); + } + + @SuppressWarnings("PMD.NPathComplexity") + protected McpSchema.ServerCapabilities computeCapabilities() { + var builder = McpSchema.ServerCapabilities.builder(); + + if (!joobyMcpServer.getTools().isEmpty()) { + builder.tools(true); + } + + if (!joobyMcpServer.getPrompts().isEmpty()) { + builder.prompts(true); + } + + if (!joobyMcpServer.getCompletions().isEmpty()) { + builder.completions(); + } + + if (!joobyMcpServer.getResources().isEmpty()) { + builder.resources(true, true); + } + + return builder.build(); + } +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpCompletionHandler.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpCompletionHandler.java new file mode 100644 index 0000000000..a1cc2fcd6b --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpCompletionHandler.java @@ -0,0 +1,74 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.mcp; + +import static io.modelcontextprotocol.spec.McpSchema.ErrorCodes.INTERNAL_ERROR; + +import java.util.List; +import java.util.Objects; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.jooby.mcp.JoobyMcpServer; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema; + +/** + * @author kliushnichenko + */ +class McpCompletionHandler { + + private static final Logger LOG = LoggerFactory.getLogger(McpCompletionHandler.class); + + public static McpSchema.CompleteResult handle( + JoobyMcpServer server, McpSchema.CompleteRequest request) { + try { + var identifier = request.ref().identifier(); + var argName = request.argument().name(); + var argValue = request.argument().value(); + + Object result = server.invokeCompletion(identifier, argName, argValue); + + return toCompleteResult(result); + } catch (Exception ex) { + LOG.error("Error invoking prompt completion '{}':", request.ref().identifier(), ex); + throw new McpError( + new McpSchema.JSONRPCResponse.JSONRPCError(INTERNAL_ERROR, ex.getMessage(), null)); + } + } + + @SuppressWarnings("PMD.NcssCount") + private static McpSchema.CompleteResult toCompleteResult(Object result) { + Objects.requireNonNull(result, "Completion result cannot be null"); + + if (result instanceof McpSchema.CompleteResult completeResult) { + return completeResult; + } else if (result instanceof McpSchema.CompleteResult.CompleteCompletion completion) { + return new McpSchema.CompleteResult(completion); + } else if (result instanceof List values) { + if (values.isEmpty()) { + return new McpSchema.CompleteResult( + new McpSchema.CompleteResult.CompleteCompletion(List.of(), 0, false)); + } else { + var item = values.getFirst(); + if (item instanceof String) { + //noinspection unchecked + return new McpSchema.CompleteResult( + new McpSchema.CompleteResult.CompleteCompletion( + (List) values, values.size(), false)); + } + } + } else if (result instanceof String singleValue) { + var completion = + new McpSchema.CompleteResult.CompleteCompletion(List.of(singleValue), 1, false); + return new McpSchema.CompleteResult(completion); + } + + LOG.error("Unsupported completion result type: {}", result.getClass().getName()); + throw new IllegalStateException("Unexpected error occurred while handling completion result"); + } +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpPromptHandler.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpPromptHandler.java new file mode 100644 index 0000000000..14b78c0a97 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpPromptHandler.java @@ -0,0 +1,96 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.mcp; + +import static io.modelcontextprotocol.spec.McpSchema.ErrorCodes.INTERNAL_ERROR; +import static io.modelcontextprotocol.spec.McpSchema.ErrorCodes.INVALID_PARAMS; +import static io.modelcontextprotocol.spec.McpSchema.Role.USER; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.jooby.mcp.JoobyMcpServer; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema; + +/** + * @author kliushnichenko + */ +class McpPromptHandler { + + private static final Logger LOG = LoggerFactory.getLogger(McpPromptHandler.class); + + public static McpSchema.GetPromptResult handle( + JoobyMcpServer server, McpSchema.GetPromptRequest request, McpSyncServerExchange exchange) { + var promptName = request.name(); + if (!server.getPrompts().containsKey(promptName)) { + throwUnknownPromptErr(promptName); + } + + try { + Object result = server.invokePrompt(promptName, request.arguments(), exchange); + return toPromptResult(result); + } catch (Exception ex) { + LOG.error("Error invoking prompt '{}':", request.name(), ex); + throw new McpError( + new McpSchema.JSONRPCResponse.JSONRPCError(INTERNAL_ERROR, ex.getMessage(), null)); + } + } + + @SuppressWarnings("PMD.NcssCount") + private static McpSchema.GetPromptResult toPromptResult(Object result) { + if (result == null) { + return new McpSchema.GetPromptResult(null, List.of()); + } else if (result instanceof McpSchema.GetPromptResult promptResult) { + return promptResult; + } else if (result instanceof McpSchema.PromptMessage promptMessage) { + return new McpSchema.GetPromptResult(null, List.of(promptMessage)); + } else if (result instanceof McpSchema.Content content) { + var promptMessage = new McpSchema.PromptMessage(USER, content); + return new McpSchema.GetPromptResult(null, List.of(promptMessage)); + } else if (result instanceof String str) { + var promptMessage = new McpSchema.PromptMessage(USER, new McpSchema.TextContent(str)); + return new McpSchema.GetPromptResult(null, List.of(promptMessage)); + } else if (result instanceof List items) { + //noinspection unchecked + return handleListReturnType((List) result, items); + } else { + var promptMessage = + new McpSchema.PromptMessage(USER, new McpSchema.TextContent(result.toString())); + return new McpSchema.GetPromptResult(null, List.of(promptMessage)); + } + } + + private static McpSchema.GetPromptResult handleListReturnType( + List result, List items) { + if (items.isEmpty()) { + return new McpSchema.GetPromptResult(null, List.of()); + } else { + var item = items.getFirst(); + if (item instanceof McpSchema.PromptMessage) { + return new McpSchema.GetPromptResult(null, result); + } else { + var msgs = + items.stream() + .map( + i -> new McpSchema.PromptMessage(USER, new McpSchema.TextContent(i.toString()))) + .toList(); + return new McpSchema.GetPromptResult(null, msgs); + } + } + } + + private static void throwUnknownPromptErr(String promptName) { + throw new McpError( + new McpSchema.JSONRPCResponse.JSONRPCError( + INVALID_PARAMS, + "Unknown prompt name '" + promptName + "'. Please verify such a prompt is registered.", + null)); + } +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpResourceHandler.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpResourceHandler.java new file mode 100644 index 0000000000..e0c390db29 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpResourceHandler.java @@ -0,0 +1,84 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.mcp; + +import static io.modelcontextprotocol.spec.McpSchema.ErrorCodes.INTERNAL_ERROR; + +import java.io.IOException; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.jooby.mcp.JoobyMcpServer; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema; + +/** + * @author kliushnichenko + */ +class McpResourceHandler { + + private static final Logger LOG = LoggerFactory.getLogger(McpResourceHandler.class); + + private final McpJsonMapper mcpJsonMapper; + + public McpResourceHandler(McpJsonMapper mcpJsonMapper) { + this.mcpJsonMapper = mcpJsonMapper; + } + + public McpSchema.ReadResourceResult handle( + JoobyMcpServer server, McpSchema.ReadResourceRequest request) { + var uri = request.uri(); + + try { + Object result = server.readResource(uri); + return toResourceResult(result, uri, mcpJsonMapper); + } catch (Exception ex) { + LOG.error("Error reading resource by URI '{}':", uri, ex); + throw new McpError( + new McpSchema.JSONRPCResponse.JSONRPCError(INTERNAL_ERROR, ex.getMessage(), null)); + } + } + + static McpSchema.ReadResourceResult toResourceResult( + Object result, String uri, McpJsonMapper mcpJsonMapper) throws IOException { + if (result == null) { + return new McpSchema.ReadResourceResult(List.of()); + } else if (result instanceof McpSchema.ReadResourceResult resourceResult) { + return resourceResult; + } else if (result instanceof McpSchema.ResourceContents resourceContents) { + return new McpSchema.ReadResourceResult(List.of(resourceContents)); + } else if (result instanceof List contents) { + return handleListReturnType(result, uri, mcpJsonMapper, contents); + } else { + return toJsonResult(result, uri, mcpJsonMapper); + } + } + + private static McpSchema.ReadResourceResult handleListReturnType( + Object result, String uri, McpJsonMapper mcpJsonMapper, List contents) throws IOException { + if (contents.isEmpty()) { + return new McpSchema.ReadResourceResult(List.of()); + } else { + var item = contents.getFirst(); + if (item instanceof McpSchema.ResourceContents) { + //noinspection unchecked + return new McpSchema.ReadResourceResult((List) contents); + } else { + return toJsonResult(result, uri, mcpJsonMapper); + } + } + } + + static McpSchema.ReadResourceResult toJsonResult( + Object result, String uri, McpJsonMapper mcpJsonMapper) throws IOException { + var resultStr = mcpJsonMapper.writeValueAsString(result); + var content = new McpSchema.TextResourceContents(uri, "application/json", resultStr); + return new McpSchema.ReadResourceResult(List.of(content)); + } +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpResourceTemplateHandler.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpResourceTemplateHandler.java new file mode 100644 index 0000000000..35b549a720 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpResourceTemplateHandler.java @@ -0,0 +1,57 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.mcp; + +import static io.modelcontextprotocol.spec.McpSchema.ErrorCodes.INTERNAL_ERROR; + +import java.util.HashMap; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.jooby.mcp.JoobyMcpServer; +import io.jooby.mcp.ResourceUri; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.util.DefaultMcpUriTemplateManager; + +/** + * @author kliushnichenko + */ +class McpResourceTemplateHandler { + + private static final Logger LOG = LoggerFactory.getLogger(McpResourceTemplateHandler.class); + + private final McpJsonMapper mcpJsonMapper; + + public McpResourceTemplateHandler(McpJsonMapper mcpJsonMapper) { + this.mcpJsonMapper = mcpJsonMapper; + } + + public McpSchema.ReadResourceResult handle( + JoobyMcpServer server, + McpSchema.ResourceTemplate resourceTemplate, + McpSchema.ReadResourceRequest request) { + var uri = request.uri(); + var uriTemplate = resourceTemplate.uriTemplate(); + DefaultMcpUriTemplateManager manager = new DefaultMcpUriTemplateManager(uriTemplate); + + Map args = new HashMap<>(); + args.put(ResourceUri.CTX_KEY, uri); + args.putAll(manager.extractVariableValues(uri)); + + try { + Object result = server.readResourceByTemplate(uriTemplate, args); + return McpResourceHandler.toResourceResult(result, uri, mcpJsonMapper); + } catch (Exception ex) { + LOG.error("Error reading resource template by URI '{}':", uri, ex); + throw new McpError( + new McpSchema.JSONRPCResponse.JSONRPCError(INTERNAL_ERROR, ex.getMessage(), null)); + } + } +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpServerConfig.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpServerConfig.java new file mode 100644 index 0000000000..805b108718 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpServerConfig.java @@ -0,0 +1,187 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.mcp; + +import com.typesafe.config.Config; +import io.jooby.exception.StartupException; + +/** + * @author kliushnichenko + */ +public class McpServerConfig { + public static final String DEFAULT_SSE_ENDPOINT = "/mcp/sse"; + public static final String DEFAULT_MESSAGE_ENDPOINT = "/mcp/message"; + public static final String DEFAULT_MCP_ENDPOINT = "/mcp"; + + private String name; + private String version; + private Transport transport; + private String sseEndpoint; + private String messageEndpoint; + private String mcpEndpoint = DEFAULT_MCP_ENDPOINT; + private boolean disallowDelete; + private Integer keepAliveInterval; + private String instructions; + + public McpServerConfig(String name, String version) { + this.name = name; + this.version = version; + } + + public enum Transport { + SSE("sse"), + STREAMABLE_HTTP("streamable-http"), + STATELESS_STREAMABLE_HTTP("stateless-streamable-http"); + + private final String value; + + Transport(String value) { + this.value = value; + } + + public static Transport of(String value) { + for (Transport transport : values()) { + if (transport.value.equalsIgnoreCase(value)) { + return transport; + } + } + throw new IllegalArgumentException("Unknown transport value: " + value); + } + + public String getValue() { + return value; + } + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } + + public Transport getTransport() { + return transport; + } + + public void setTransport(Transport transport) { + this.transport = transport; + } + + public String getSseEndpoint() { + return sseEndpoint; + } + + public void setSseEndpoint(String sseEndpoint) { + this.sseEndpoint = sseEndpoint; + } + + public String getMessageEndpoint() { + return messageEndpoint; + } + + public void setMessageEndpoint(String messageEndpoint) { + this.messageEndpoint = messageEndpoint; + } + + public String getMcpEndpoint() { + return mcpEndpoint; + } + + public void setMcpEndpoint(String mcpEndpoint) { + this.mcpEndpoint = mcpEndpoint; + } + + public boolean isDisallowDelete() { + return disallowDelete; + } + + public void setDisallowDelete(boolean disallowDelete) { + this.disallowDelete = disallowDelete; + } + + public Integer getKeepAliveInterval() { + return keepAliveInterval; + } + + public void setKeepAliveInterval(Integer keepAliveInterval) { + this.keepAliveInterval = keepAliveInterval; + } + + public String getInstructions() { + return instructions; + } + + public void setInstructions(String instructions) { + this.instructions = instructions; + } + + public static McpServerConfig fromConfig(Config config) { + var srvConfig = + new McpServerConfig( + resolveRequiredParam(config, "name"), resolveRequiredParam(config, "version")); + + if (config.hasPath("transport")) { + Transport transport = Transport.of(config.getString("transport")); + srvConfig.setTransport(transport); + } else { + srvConfig.setTransport(Transport.STREAMABLE_HTTP); + } + + srvConfig.setSseEndpoint(getStrProp("sseEndpoint", DEFAULT_SSE_ENDPOINT, config)); + srvConfig.setMessageEndpoint(getStrProp("messageEndpoint", DEFAULT_MESSAGE_ENDPOINT, config)); + srvConfig.setMcpEndpoint(getStrProp("mcpEndpoint", DEFAULT_MCP_ENDPOINT, config)); + srvConfig.setInstructions(getStrProp("instructions", null, config)); + srvConfig.setDisallowDelete(getBoolProp("disallowDelete", false, config)); + srvConfig.setKeepAliveInterval(getIntProp("keepAliveInterval", null, config)); + + return srvConfig; + } + + public boolean isSseTransport() { + return this.transport == Transport.SSE; + } + + private static String resolveRequiredParam(Config config, String configPath) { + if (!config.hasPath(configPath)) { + throw new StartupException("Missing required config path: " + configPath); + } + return config.getString(configPath); + } + + private static String getStrProp(String propName, String defaultValue, Config config) { + if (config.hasPath(propName)) { + return config.getString(propName); + } else { + return defaultValue; + } + } + + private static boolean getBoolProp(String propName, boolean defaultValue, Config config) { + if (config.hasPath(propName)) { + return config.getBoolean(propName); + } else { + return defaultValue; + } + } + + private static Integer getIntProp(String propName, Integer defaultValue, Config config) { + if (config.hasPath(propName)) { + return config.getInt(propName); + } else { + return defaultValue; + } + } +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpStatelessServerRunner.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpStatelessServerRunner.java new file mode 100644 index 0000000000..d9fd72dbac --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpStatelessServerRunner.java @@ -0,0 +1,142 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.mcp; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.jooby.Jooby; +import io.jooby.ServiceKey; +import io.jooby.mcp.JoobyMcpServer; +import io.jooby.mcp.transport.JoobyStatelessServerTransport; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpStatelessServerFeatures; +import io.modelcontextprotocol.server.McpStatelessSyncServer; +import io.modelcontextprotocol.spec.McpSchema; + +/** + * @author kliushnichenko + */ +public class McpStatelessServerRunner extends BaseMcpServerRunner { + + private static final Logger LOG = LoggerFactory.getLogger(McpStatelessServerRunner.class); + + public McpStatelessServerRunner( + Jooby app, + JoobyMcpServer joobyMcpServer, + McpServerConfig serverConfig, + McpJsonMapper mcpJsonMapper, + boolean isSingleServer) { + super(app, joobyMcpServer, serverConfig, mcpJsonMapper, isSingleServer); + } + + @Override + protected McpStatelessSyncServer initMcpServer() { + List completions = initCompletions(); + + var transportProvider = + new JoobyStatelessServerTransport(app, mcpJsonMapper, serverConfig, CTX_EXTRACTOR); + return McpServer.sync(transportProvider) + .serverInfo(serverConfig.getName(), serverConfig.getVersion()) + .capabilities(computeCapabilities()) + .completions(completions) + .instructions(serverConfig.getInstructions()) + .build(); + } + + private List initCompletions() { + List completions = new ArrayList<>(); + for (McpSchema.CompleteReference ref : joobyMcpServer.getCompletions()) { + var completion = + new McpStatelessServerFeatures.SyncCompletionSpecification( + ref, (ctx, request) -> McpCompletionHandler.handle(joobyMcpServer, request)); + completions.add(completion); + } + return completions; + } + + @Override + protected void initTools(McpStatelessSyncServer mcpServer) { + for (Map.Entry entry : joobyMcpServer.getTools().entrySet()) { + ToolSpec toolSpec = entry.getValue(); + var syncToolSpec = + new McpStatelessServerFeatures.SyncToolSpecification.Builder() + .tool(buildTool(toolSpec)) + .callHandler((ctx, request) -> toolHandler.handle(request, joobyMcpServer, null)) + .build(); + + mcpServer.addTool(syncToolSpec); + } + } + + @Override + protected void initPrompts(McpStatelessSyncServer mcpServer) { + for (Map.Entry entry : joobyMcpServer.getPrompts().entrySet()) { + mcpServer.addPrompt( + new McpStatelessServerFeatures.SyncPromptSpecification( + entry.getValue(), + (ctx, request) -> McpPromptHandler.handle(joobyMcpServer, request, null))); + } + } + + @Override + protected void initResources(McpStatelessSyncServer mcpServer) { + for (McpSchema.Resource resource : joobyMcpServer.getResources()) { + mcpServer.addResource( + new McpStatelessServerFeatures.SyncResourceSpecification( + resource, (ctx, request) -> resourceHandler.handle(joobyMcpServer, request))); + } + } + + @Override + protected void initResourceTemplates(McpStatelessSyncServer mcpServer) { + for (McpSchema.ResourceTemplate template : joobyMcpServer.getResourceTemplates()) { + var syncTemplateSpec = + new McpStatelessServerFeatures.SyncResourceTemplateSpecification( + template, + (ctx, request) -> resourceTemplateHandler.handle(joobyMcpServer, template, request)); + mcpServer.addResourceTemplate(syncTemplateSpec); + } + } + + @Override + protected void addToJoobyRegistry(McpStatelessSyncServer mcpServer) { + var registry = app.getServices(); + if (isSingleServer) { + registry.put(McpStatelessSyncServer.class, mcpServer); + } else { + var serviceKey = ServiceKey.key(McpStatelessSyncServer.class, joobyMcpServer.getServerKey()); + registry.put(serviceKey, mcpServer); + } + } + + @Override + protected void close(McpStatelessSyncServer mcpServer) { + mcpServer.close(); + } + + @Override + protected void logMcpStart(McpStatelessSyncServer mcpServer) { + LOG.info( + """ + + MCP server started with: + name: {} + version: {} + transport: {} + capabilities: {} + """, + mcpServer.getServerInfo().name(), + mcpServer.getServerInfo().version(), + serverConfig.getTransport().getValue(), + mcpServer.getServerCapabilities()); + } +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpSyncServerRunner.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpSyncServerRunner.java new file mode 100644 index 0000000000..6f41c24b1d --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpSyncServerRunner.java @@ -0,0 +1,166 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.mcp; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.jooby.Jooby; +import io.jooby.ServiceKey; +import io.jooby.mcp.JoobyMcpServer; +import io.jooby.mcp.transport.JoobySseTransportProvider; +import io.jooby.mcp.transport.JoobyStreamableServerTransportProvider; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.spec.McpSchema; + +/** + * @author kliushnichenko + */ +public class McpSyncServerRunner extends BaseMcpServerRunner { + + private static final Logger LOG = LoggerFactory.getLogger(McpSyncServerRunner.class); + + public McpSyncServerRunner( + Jooby app, + JoobyMcpServer joobyMcpServer, + McpServerConfig serverConfig, + McpJsonMapper mcpJsonMapper, + boolean isSingleServer) { + super(app, joobyMcpServer, serverConfig, mcpJsonMapper, isSingleServer); + } + + @Override + protected McpSyncServer initMcpServer() { + List completions = initCompletions(); + + if (McpServerConfig.Transport.SSE == serverConfig.getTransport()) { + var transportProvider = new JoobySseTransportProvider(app, serverConfig, mcpJsonMapper); + return McpServer.sync(transportProvider) + .serverInfo(serverConfig.getName(), serverConfig.getVersion()) + .capabilities(computeCapabilities()) + .completions(completions) + .instructions(serverConfig.getInstructions()) + .build(); + } else if (McpServerConfig.Transport.STREAMABLE_HTTP == serverConfig.getTransport()) { + var transportProvider = + new JoobyStreamableServerTransportProvider( + app, mcpJsonMapper, serverConfig, CTX_EXTRACTOR); + + return McpServer.sync(transportProvider) + .serverInfo(serverConfig.getName(), serverConfig.getVersion()) + .capabilities(computeCapabilities()) + .completions(completions) + .instructions(serverConfig.getInstructions()) + .build(); + } else { + throw new IllegalStateException("Unsupported transport: " + serverConfig.getTransport()); + } + } + + private List initCompletions() { + List completions = new ArrayList<>(); + for (McpSchema.CompleteReference ref : joobyMcpServer.getCompletions()) { + var completion = + new McpServerFeatures.SyncCompletionSpecification( + ref, (exchange, request) -> McpCompletionHandler.handle(joobyMcpServer, request)); + completions.add(completion); + } + return completions; + } + + @Override + protected void initTools(McpSyncServer mcpServer) { + for (Map.Entry entry : joobyMcpServer.getTools().entrySet()) { + ToolSpec toolSpec = entry.getValue(); + + var syncToolSpec = + new McpServerFeatures.SyncToolSpecification.Builder() + .tool(buildTool(toolSpec)) + .callHandler( + (exchange, request) -> toolHandler.handle(request, joobyMcpServer, exchange)) + .build(); + + mcpServer.addTool(syncToolSpec); + } + } + + @Override + protected void initPrompts(McpSyncServer mcpServer) { + for (Map.Entry entry : joobyMcpServer.getPrompts().entrySet()) { + mcpServer.addPrompt( + new McpServerFeatures.SyncPromptSpecification( + entry.getValue(), + (exchange, request) -> McpPromptHandler.handle(joobyMcpServer, request, exchange))); + } + } + + @Override + protected void initResources(McpSyncServer mcpServer) { + for (McpSchema.Resource resource : joobyMcpServer.getResources()) { + mcpServer.addResource( + new McpServerFeatures.SyncResourceSpecification( + resource, (exchange, request) -> resourceHandler.handle(joobyMcpServer, request))); + } + } + + @Override + protected void initResourceTemplates(McpSyncServer mcpServer) { + for (McpSchema.ResourceTemplate template : joobyMcpServer.getResourceTemplates()) { + var syncTemplateSpec = + new McpServerFeatures.SyncResourceTemplateSpecification( + template, + (exchange, request) -> + resourceTemplateHandler.handle(joobyMcpServer, template, request)); + mcpServer.addResourceTemplate(syncTemplateSpec); + } + } + + @Override + protected void addToJoobyRegistry(McpSyncServer mcpServer) { + var registry = app.getServices(); + if (isSingleServer) { + registry.put(McpSyncServer.class, mcpServer); + } else { + var serviceKey = ServiceKey.key(McpSyncServer.class, joobyMcpServer.getServerKey()); + registry.put(serviceKey, mcpServer); + } + } + + @Override + protected void close(McpSyncServer mcpServer) { + mcpServer.close(); + } + + @Override + protected void logMcpStart(McpSyncServer mcpServer) { + LOG.info( + """ + + MCP server started with: + name: {} + version: {} + transport: {} + keepAliveInterval: {} + disallowDelete: {} + capabilities: {} + """, + mcpServer.getServerInfo().name(), + mcpServer.getServerInfo().version(), + serverConfig.getTransport().getValue(), + serverConfig.getKeepAliveInterval() == null + ? "N/A" + : serverConfig.getKeepAliveInterval() + " s", + serverConfig.isDisallowDelete(), + mcpServer.getServerCapabilities()); + } +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpToolHandler.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpToolHandler.java new file mode 100644 index 0000000000..d3e32aa167 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpToolHandler.java @@ -0,0 +1,100 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.mcp; + +import static io.modelcontextprotocol.spec.McpSchema.ErrorCodes.INVALID_PARAMS; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.jooby.mcp.JoobyMcpServer; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema; + +/** + * @author kliushnichenko + */ +public class McpToolHandler { + + private static final Logger LOG = LoggerFactory.getLogger(McpToolHandler.class); + + private final McpJsonMapper mcpJsonMapper; + + public McpToolHandler(McpJsonMapper mcpJsonMapper) { + this.mcpJsonMapper = mcpJsonMapper; + } + + public McpSchema.CallToolResult handle( + McpSchema.CallToolRequest request, JoobyMcpServer server, McpSyncServerExchange exchange) { + String toolName = request.name(); + ToolSpec toolSpec = server.getTools().get(toolName); + if (toolSpec == null) { + throwUnknownToolErr(toolName); + } + try { + verifyRequiredArguments(request.arguments(), toolSpec.getRequiredArguments()); + + Object result = server.invokeTool(toolName, request.arguments(), exchange); + return toCallToolResult(toolSpec, result); + } catch (Exception ex) { + LOG.error("Error invoking tool '{}':", toolName, ex); + return buildTextResult(ex.getMessage(), true); + } + } + + private McpSchema.CallToolResult toCallToolResult(ToolSpec spec, Object result) + throws IOException { + var hasOutputSchema = spec.getOutputSchema() != null; + if (result == null) { + return buildTextResult("null", false); + } else if (result instanceof McpSchema.CallToolResult callToolResult) { + return callToolResult; + } else if (result instanceof String str) { + return buildTextResult(str, false); + } else if (result instanceof McpSchema.Content content) { + return McpSchema.CallToolResult.builder().content(List.of(content)).isError(false).build(); + } else { + if (hasOutputSchema) { + return McpSchema.CallToolResult.builder().structuredContent(result).isError(false).build(); + } else { + var resultStr = mcpJsonMapper.writeValueAsString(result); + return buildTextResult(resultStr, false); + } + } + } + + private void verifyRequiredArguments( + Map actualArguments, List requiredArguments) { + for (String requiredArg : requiredArguments) { + var argument = actualArguments.get(requiredArg); + if (argument == null) { + throw new IllegalArgumentException("Missing required argument: " + requiredArg); + } + + if (argument instanceof String str && str.isEmpty()) { + throw new IllegalArgumentException("Required argument is empty: " + requiredArg); + } + } + } + + private McpSchema.CallToolResult buildTextResult(String text, boolean isError) { + return McpSchema.CallToolResult.builder().addTextContent(text).isError(isError).build(); + } + + private static void throwUnknownToolErr(String toolName) { + throw new McpError( + new McpSchema.JSONRPCResponse.JSONRPCError( + INVALID_PARAMS, + "Unknown tool '" + toolName + "'. Please verify such a tool is registered.", + null)); + } +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/MethodInvoker.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/MethodInvoker.java new file mode 100644 index 0000000000..3cd17b49c1 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/MethodInvoker.java @@ -0,0 +1,25 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.mcp; + +import java.util.Map; + +import io.modelcontextprotocol.server.McpSyncServerExchange; + +/** + * @author kliushnichenko + */ +@FunctionalInterface +public interface MethodInvoker { + /** + * Invokes a method with the provided arguments and exchange context. + * + * @param args a map of argument names to values + * @param exchange the server exchange context + * @return the result of the method invocation + */ + Object invoke(final Map args, McpSyncServerExchange exchange); +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/ToolSpec.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/ToolSpec.java new file mode 100644 index 0000000000..373d748d43 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/ToolSpec.java @@ -0,0 +1,121 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.mcp; + +import java.util.List; + +import io.modelcontextprotocol.spec.McpSchema; + +/** + * @author kliushnichenko + */ +public class ToolSpec { + public static class Builder { + private ToolSpec spec = new ToolSpec(); + + public ToolSpec build() { + return spec; + } + + public Builder name(String name) { + this.spec.setName(name); + return this; + } + + public Builder title(String title) { + this.spec.setTitle(title); + return this; + } + + public Builder description(String description) { + this.spec.setDescription(description); + return this; + } + + public Builder inputSchema(String inputSchema) { + this.spec.setInputSchema(inputSchema); + return this; + } + + public Builder outputSchema(String outputSchema) { + this.spec.setOutputSchema(outputSchema); + return this; + } + + public Builder requiredArguments(List requiredArguments) { + this.spec.setRequiredArguments(requiredArguments); + return this; + } + } + + private String name; + private String title; + private String description; + private String inputSchema; + private String outputSchema; + private List requiredArguments; + private McpSchema.ToolAnnotations annotations; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getInputSchema() { + return inputSchema; + } + + public void setInputSchema(String inputSchema) { + this.inputSchema = inputSchema; + } + + public String getOutputSchema() { + return outputSchema; + } + + public void setOutputSchema(String outputSchema) { + this.outputSchema = outputSchema; + } + + public List getRequiredArguments() { + return requiredArguments; + } + + public void setRequiredArguments(List requiredArguments) { + this.requiredArguments = requiredArguments; + } + + public McpSchema.ToolAnnotations getAnnotations() { + return annotations; + } + + public void setAnnotations(McpSchema.ToolAnnotations annotations) { + this.annotations = annotations; + } + + public static Builder builder() { + return new Builder(); + } +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/JoobyMcpServer.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/JoobyMcpServer.java new file mode 100644 index 0000000000..3e9d109208 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/JoobyMcpServer.java @@ -0,0 +1,45 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.mcp; + +import java.util.List; +import java.util.Map; + +import io.jooby.Jooby; +import io.jooby.internal.mcp.ToolSpec; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpSchema; + +/** + * @author kliushnichenko + */ +public interface JoobyMcpServer { + + String getServerKey(); + + void init(Jooby app, McpJsonMapper mcpJsonMapper); + + Object invokeTool(String toolName, Map args, McpSyncServerExchange exchange); + + Object invokePrompt(String promptName, Map args, McpSyncServerExchange exchange); + + Object invokeCompletion(String identifier, String argumentName, String input); + + Object readResource(String uri); + + Object readResourceByTemplate(String uri, Map templateArgs); + + Map getTools(); + + Map getPrompts(); + + List getResources(); + + List getResourceTemplates(); + + List getCompletions(); +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java new file mode 100644 index 0000000000..f9757cac7f --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java @@ -0,0 +1,171 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.mcp; + +import static io.jooby.internal.mcp.McpServerConfig.Transport.STATELESS_STREAMABLE_HTTP; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.typesafe.config.Config; +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.Extension; +import io.jooby.Jooby; +import io.jooby.exception.StartupException; +import io.jooby.internal.mcp.BaseMcpServerRunner; +import io.jooby.internal.mcp.McpServerConfig; +import io.jooby.internal.mcp.McpStatelessServerRunner; +import io.jooby.internal.mcp.McpSyncServerRunner; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; + +/** + * MCP (Model Context Protocol) module for Jooby. + * + *

The MCP module provides integration with the Model Context Protocol server, enabling + * standardized communication between clients and servers. It allows applications to: + * + *

+ * + *

Usage

+ * + *

Add the module to your application: + * + *

{@code
+ * {
+ *   install(new JacksonModule());
+ *   install(new McpModule(new DefaultMcpServer()));
+ * }
+ * }
+ * + *

Configuration

+ * + *

The module requires the following configuration in your application.conf: + * + *

{@code
+ * mcp.default {
+ *     name: "my-awesome-mcp-server"     # Required
+ *     version: "0.0.1"                  # Required
+ *     sseEndpoint: "/mcp/sse"           # Optional (default: /mcp/sse)
+ *     messageEndpoint: "/mcp/message"   # Optional (default: /mcp/message)
+ * }
+ * }
+ * + *

Features

+ * + * + * + *

Multiple servers

+ * + *

To run multiple MCP server instances in the same application, use a @McpServer("calculator") + * annotation: + * + *

{@code
+ * {
+ *
+ *   install(new JacksonModule());
+ *   install(new McpModule(new DefaultMcpServer(), new CalculatorMcpServer()));
+ * }
+ * }
+ * + *

Each instance requires its own configuration block: + * + *

{@code
+ * mcp {
+ *  default {
+ *    name: "default-mcp-server"
+ *    version: "1.0.0"
+ *    sseEndpoint: "/mcp/sse"
+ *    messageEndpoint: "/mcp/message"
+ *  }
+ *  calculator {
+ *    name: "calculator-mcp-server"
+ *    version: "1.0.0"
+ *    sseEndpoint: "/mcp/calculator/sse"
+ *    messageEndpoint: "/mcp/calculator/message"
+ *  }
+ * }
+ *
+ * }
+ * + * @author kliushnichenko + * @since 1.0.0 + */ +public class McpModule implements Extension { + + private static final String MODULE_CONFIG_PREFIX = "mcp"; + + private McpJsonMapper mcpJsonMapper = new JacksonMcpJsonMapper(new ObjectMapper()); + private final List mcpServers = new ArrayList<>(); + + public McpModule(JoobyMcpServer joobyMcpServer, JoobyMcpServer... moreMcpServers) { + mcpServers.add(joobyMcpServer); + if (moreMcpServers != null) { + Collections.addAll(mcpServers, moreMcpServers); + } + } + + @Override + public void install(@NonNull Jooby app) { + Config config = app.getConfig(); + if (!config.hasPath(MODULE_CONFIG_PREFIX)) { + throw new StartupException("Missing required config path: " + MODULE_CONFIG_PREFIX); + } + + for (var joobyMcpServer : mcpServers) { + var serverConfig = resolveServerConfig(config, joobyMcpServer.getServerKey()); + // Load definitions from generated code. + joobyMcpServer.init(app, mcpJsonMapper); + + // transport + dedicated mcp server + var runner = buildMcpServerRunner(app, joobyMcpServer, serverConfig); + runner.run(); + app.getServices().listOf(McpServerConfig.class).add(serverConfig); + } + } + + private BaseMcpServerRunner buildMcpServerRunner( + Jooby app, JoobyMcpServer joobyMcpServer, McpServerConfig serverConfig) { + var isSingleServer = hasSingleMcpServer(); + if (STATELESS_STREAMABLE_HTTP == serverConfig.getTransport()) { + return new McpStatelessServerRunner( + app, joobyMcpServer, serverConfig, mcpJsonMapper, isSingleServer); + } else { + return new McpSyncServerRunner( + app, joobyMcpServer, serverConfig, mcpJsonMapper, isSingleServer); + } + } + + private boolean hasSingleMcpServer() { + return this.mcpServers.size() == 1; + } + + private McpServerConfig resolveServerConfig(Config config, String serverKey) { + String path = MODULE_CONFIG_PREFIX + "." + serverKey; + if (!config.hasPath(path)) { + throw new StartupException(String.format("Missing required config path: %s", path)); + } + return McpServerConfig.fromConfig(config.getConfig(path)); + } + + public McpModule mcpJsonMapper(McpJsonMapper mcpJsonMapper) { + this.mcpJsonMapper = mcpJsonMapper; + return this; + } +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpResult.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpResult.java new file mode 100644 index 0000000000..36d194b74e --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpResult.java @@ -0,0 +1,30 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.mcp; + +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.spec.McpSchema; + +public class McpResult { + + public McpResult(McpJsonMapper json) {} + + public McpSchema.CallToolResult toCallToolResult(Object result) { + return null; + } + + public McpSchema.GetPromptResult toPromptResult(Object result) { + return null; + } + + public McpSchema.ReadResourceResult toResourceResult(Object result) { + return null; + } + + public McpSchema.CompleteResult toCompleteResult(Object result) { + return null; + } +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java index a25ebdee19..b2d3e54e41 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java @@ -5,37 +5,23 @@ */ package io.jooby.mcp; -import java.util.Map; +import java.util.List; -import io.modelcontextprotocol.server.McpSyncServerExchange; +import edu.umd.cs.findbugs.annotations.Nullable; +import io.jooby.Jooby; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.spec.McpSchema; /** High-performance dispatcher interface generated by the Jooby APT for MCP endpoints. */ public interface McpService { - default Object invokeTool(String name, Map args, McpSyncServerExchange exchange) - throws Exception { - throw new UnsupportedOperationException("tool: " + name + " not supported"); - } + void install(Jooby application, McpSyncServer server, McpJsonMapper json) throws Exception; - default Object invokePrompt(String name, Map args, McpSyncServerExchange exchange) - throws Exception { - throw new UnsupportedOperationException("prompt: " + name + " not supported"); - } + void capabilities(McpSchema.ServerCapabilities.Builder capabilities); - default Object readResource(String name, Map args, McpSyncServerExchange exchange) - throws Exception { - throw new UnsupportedOperationException("resource: " + name + " not supported"); - } + List completions(); - default Object readResourceByTemplate( - String templateUri, Map args, McpSyncServerExchange exchange) - throws Exception { - throw new UnsupportedOperationException("resource: " + templateUri + " not supported"); - } - - default Object invokeCompletion( - String identifier, String argumentName, String input, McpSyncServerExchange exchange) - throws Exception { - throw new UnsupportedOperationException("completion: " + identifier + " not supported"); - } + @Nullable String serverName(); } diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/ResourceUri.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/ResourceUri.java new file mode 100644 index 0000000000..13d55a7b8f --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/ResourceUri.java @@ -0,0 +1,13 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.mcp; + +/** + * @author kliushnichenko + */ +public record ResourceUri(String uri) { + public static final String CTX_KEY = "__resourceUri"; +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobySseTransportProvider.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobySseTransportProvider.java new file mode 100644 index 0000000000..090b053dc7 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobySseTransportProvider.java @@ -0,0 +1,213 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.mcp.transport; + +import static io.jooby.mcp.transport.TransportConstants.*; + +import java.io.IOException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.jooby.*; +import io.jooby.internal.mcp.McpServerConfig; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.spec.*; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Provides SSE transport implementation for MCP server using Jooby framework. Handles client + * connections, message routing, and session management. + */ +@SuppressWarnings("PMD") +public class JoobySseTransportProvider implements McpServerTransportProvider { + + private static final Logger LOG = LoggerFactory.getLogger(JoobySseTransportProvider.class); + + private static final String ENDPOINT_EVENT_TYPE = "endpoint"; + private static final String SESSION_ID_KEY = "sessionId"; + + private final String messageEndpoint; + private final McpJsonMapper mcpJsonMapper; + private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); + + private McpServerSession.Factory sessionFactory; + private final AtomicBoolean isClosing = new AtomicBoolean(false); + + /** + * Constructs a new Jooby Reactive SSE transport provider instance. + * + * @param app The Jooby application instance to register endpoints with + * @param serverConfig The MCP server configuration containing endpoint settings + * @param mcpJsonMapper The MCP JSON mapper for message serialization/deserialization + */ + public JoobySseTransportProvider( + Jooby app, McpServerConfig serverConfig, McpJsonMapper mcpJsonMapper) { + this.mcpJsonMapper = mcpJsonMapper; + this.messageEndpoint = serverConfig.getMessageEndpoint(); + String sseEndpoint = serverConfig.getSseEndpoint(); + + app.head(sseEndpoint, ctx -> StatusCode.OK).produces(TEXT_EVENT_STREAM); + app.sse(sseEndpoint, this::handleSseConnection); + app.post(this.messageEndpoint, this::handleMessage); + } + + @Override + public void setSessionFactory(McpServerSession.Factory sessionFactory) { + this.sessionFactory = sessionFactory; + } + + @Override + public Mono notifyClients(String method, Object params) { + if (sessions.isEmpty()) { + LOG.debug("No active sessions to broadcast message to"); + return Mono.empty(); + } + + if (LOG.isDebugEnabled()) { + LOG.debug("Attempting to broadcast message to {} active sessions", sessions.size()); + } + + return Flux.fromIterable(sessions.values()) + .flatMap( + session -> + session + .sendNotification(method, params) + .doOnError( + e -> + LOG.error( + "Failed to send message to session {}: {}", + session.getId(), + e.getMessage())) + .onErrorComplete()) + .then(); + } + + @Override + public Mono closeGracefully() { + return Flux.fromIterable(sessions.values()) + .doFirst( + () -> { + isClosing.set(true); + if (LOG.isDebugEnabled()) { + LOG.debug("Initiating graceful shutdown with {} active sessions", sessions.size()); + } + }) + .flatMap(McpServerSession::closeGracefully) + .doFinally(signalType -> sessions.clear()) + .then(); + } + + private void handleSseConnection(ServerSentEmitter sse) { + JoobyMcpSessionTransport transport = new JoobyMcpSessionTransport(sse); + McpServerSession session = sessionFactory.create(transport); + String sessionId = session.getId(); + + LOG.debug("New SSE connection has been established. Session ID: {}", sessionId); + sessions.put(sessionId, session); + + sse.onClose( + () -> { + LOG.debug("Session with ID {} has been cancelled", sessionId); + sessions.remove(sessionId); + }); + + LOG.debug("Sending initial endpoint event to session: {}", sessionId); + sse.send( + new ServerSentMessage(this.messageEndpoint + "?sessionId=" + sessionId) + .setEvent(ENDPOINT_EVENT_TYPE)); + } + + private Object handleMessage(Context ctx) { + if (isClosing.get()) { + ctx.setResponseCode(StatusCode.SERVICE_UNAVAILABLE); + return McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR) + .message("Server is shutting down") + .build(); + } + + if (ctx.query(SESSION_ID_KEY).isMissing()) { + ctx.setResponseCode(StatusCode.BAD_REQUEST); + return McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST) + .message("Session ID missing in message endpoint") + .build(); + } + + String sessionId = ctx.query(SESSION_ID_KEY).value(); + McpServerSession session = sessions.get(sessionId); + + if (session == null) { + ctx.setResponseCode(StatusCode.NOT_FOUND); + return McpError.builder(McpSchema.ErrorCodes.RESOURCE_NOT_FOUND) + .message("Session not found: " + sessionId) + .build(); + } + + try { + var body = ctx.body().value(); + McpSchema.JSONRPCMessage message = + McpSchema.deserializeJsonRpcMessage(this.mcpJsonMapper, body); + + return session + .handle(message) + .then(Mono.just((Object) StatusCode.OK)) + .onErrorResume( + error -> { + LOG.error("Error processing message: {}", error.getMessage()); + return Mono.just(StatusCode.OK); + }) + .switchIfEmpty(Mono.just((Object) StatusCode.OK)) + .block(); + } catch (IOException | IllegalArgumentException e) { + LOG.error("Failed to deserialize message: {}", e.getMessage()); + return McpError.builder(McpSchema.ErrorCodes.PARSE_ERROR) + .message("Invalid message format") + .build(); + } + } + + private class JoobyMcpSessionTransport implements McpServerTransport { + + private final ServerSentEmitter sse; + + public JoobyMcpSessionTransport(ServerSentEmitter sse) { + this.sse = sse; + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + return Mono.fromRunnable( + () -> { + try { + String jsonText = mcpJsonMapper.writeValueAsString(message); + sse.send(new ServerSentMessage(jsonText).setEvent(MESSAGE_EVENT_TYPE)); + } catch (Exception e) { + LOG.error("Failed to send message: {}", e.getMessage()); + sse.send(SSE_ERROR_EVENT, e.getMessage()); + } + }); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return mcpJsonMapper.convertValue(data, typeRef); + } + + @Override + public Mono closeGracefully() { + return Mono.fromRunnable(sse::close); + } + + @Override + public void close() { + sse.close(); + } + } +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobyStatelessServerTransport.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobyStatelessServerTransport.java new file mode 100644 index 0000000000..227a30c9c8 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobyStatelessServerTransport.java @@ -0,0 +1,127 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.mcp.transport; + +import static io.jooby.mcp.transport.TransportConstants.TEXT_EVENT_STREAM; +import static io.modelcontextprotocol.spec.McpSchema.ErrorCodes.INVALID_REQUEST; + +import java.io.IOException; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.jooby.Context; +import io.jooby.Jooby; +import io.jooby.MediaType; +import io.jooby.StatusCode; +import io.jooby.internal.mcp.McpServerConfig; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.server.McpStatelessServerHandler; +import io.modelcontextprotocol.server.McpTransportContextExtractor; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpStatelessServerTransport; +import reactor.core.publisher.Mono; + +/** + * Jooby-based implementation of a stateless MCP server transport. Inspired by WebMvcStatelessServerTransport. + * + * @author kliushnichenko + */ +@SuppressWarnings("PMD") +public class JoobyStatelessServerTransport implements McpStatelessServerTransport { + + private static final Logger LOG = LoggerFactory.getLogger(JoobyStatelessServerTransport.class); + + private McpStatelessServerHandler mcpHandler; + private final McpJsonMapper mcpJsonMapper; + private final McpTransportContextExtractor contextExtractor; + private volatile boolean isClosing = false; + + public JoobyStatelessServerTransport( + Jooby app, + McpJsonMapper jsonMapper, + McpServerConfig serverConfig, + McpTransportContextExtractor contextExtractor) { + this.mcpJsonMapper = jsonMapper; + this.contextExtractor = contextExtractor; + + var mcpEndpoint = serverConfig.getMcpEndpoint(); + app.head(mcpEndpoint, ctx -> StatusCode.OK).produces(TEXT_EVENT_STREAM); + app.get(mcpEndpoint, this::handleGet); + app.post(mcpEndpoint, this::handlePost); + } + + private Object handlePost(Context ctx) { + if (this.isClosing) { + return SendError.serverIsShuttingDown(ctx); + } + + if (!ctx.accept(TEXT_EVENT_STREAM) || !ctx.accept(MediaType.json)) { + return SendError.invalidAcceptHeader(ctx, List.of(TEXT_EVENT_STREAM, MediaType.json)); + } + + McpTransportContext transportContext = this.contextExtractor.extract(ctx); + try { + var body = ctx.body().valueOrNull(); + if (body == null) { + return SendError.error( + ctx, StatusCode.BAD_REQUEST, INVALID_REQUEST, "Request body is missing"); + } + McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(mcpJsonMapper, body); + + if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { + try { + McpSchema.JSONRPCResponse jsonrpcResponse = + this.mcpHandler + .handleRequest(transportContext, jsonrpcRequest) + .contextWrite( + reactorCtx -> reactorCtx.put(McpTransportContext.KEY, transportContext)) + .block(); + return jsonrpcResponse; + } catch (Exception e) { + LOG.error("Failed to handle request.", e); + return SendError.internalError(ctx); + } + } else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { + try { + this.mcpHandler + .handleNotification(transportContext, jsonrpcNotification) + .contextWrite(reactorCtx -> reactorCtx.put(McpTransportContext.KEY, transportContext)) + .block(); + return StatusCode.ACCEPTED; + } catch (Exception e) { + LOG.error("Failed to handle notification", e); + return SendError.internalError(ctx); + } + } else { + return SendError.badRequest(ctx, "The server accepts either requests or notifications"); + } + } catch (IllegalArgumentException | IOException e) { + LOG.error("Failed to deserialize message.", e); + return SendError.badRequest(ctx, "Invalid message format"); + } catch (Exception e) { + LOG.error("Unexpected error handling message.", e); + return SendError.internalError(ctx); + } + } + + private Context handleGet(Context ctx) { + return ctx.setResponseCode(StatusCode.METHOD_NOT_ALLOWED); + } + + @Override + public void setMcpHandler(McpStatelessServerHandler mcpHandler) { + this.mcpHandler = mcpHandler; + } + + @Override + public Mono closeGracefully() { + return Mono.fromRunnable(() -> this.isClosing = true); + } +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobyStreamableServerTransportProvider.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobyStreamableServerTransportProvider.java new file mode 100644 index 0000000000..0c4fb62c0e --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobyStreamableServerTransportProvider.java @@ -0,0 +1,484 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.mcp.transport; + +import static io.jooby.mcp.transport.TransportConstants.*; +import static io.modelcontextprotocol.spec.McpSchema.ErrorCodes.INVALID_REQUEST; + +import java.io.IOException; +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.jooby.*; +import io.jooby.internal.mcp.McpServerConfig; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.server.McpTransportContextExtractor; +import io.modelcontextprotocol.spec.*; +import io.modelcontextprotocol.util.KeepAliveScheduler; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Jooby implementation of Streamable HTTP transport. Inspired by WebMvcStreamableServerTransportProvider + * + * @author kliushnichenko + */ +@SuppressWarnings("PMD") +public class JoobyStreamableServerTransportProvider + implements McpStreamableServerTransportProvider { + + private static final Logger LOG = + LoggerFactory.getLogger(JoobyStreamableServerTransportProvider.class); + + private final boolean disallowDelete; + private final McpJsonMapper mcpJsonMapper; + private final ConcurrentHashMap sessions = + new ConcurrentHashMap<>(); + private final McpTransportContextExtractor contextExtractor; + private volatile boolean isClosing = false; + private McpStreamableServerSession.Factory sessionFactory; + private KeepAliveScheduler keepAliveScheduler; + + public JoobyStreamableServerTransportProvider( + Jooby app, + McpJsonMapper jsonMapper, + McpServerConfig serverConfig, + McpTransportContextExtractor contextExtractor) { + Objects.requireNonNull(contextExtractor, "McpTransportContextExtractor must not be null"); + + this.mcpJsonMapper = jsonMapper; + this.disallowDelete = serverConfig.isDisallowDelete(); + this.contextExtractor = contextExtractor; + + var mcpEndpoint = serverConfig.getMcpEndpoint(); + + app.head(mcpEndpoint, ctx -> StatusCode.OK).produces(TEXT_EVENT_STREAM); + app.get(mcpEndpoint, this::handleGet); + app.post(mcpEndpoint, this::handlePost); + app.delete(mcpEndpoint, this::handleDelete); + + if (serverConfig.getKeepAliveInterval() != null) { + var keepAliveInterval = Duration.ofSeconds(serverConfig.getKeepAliveInterval()); + this.keepAliveScheduler = + KeepAliveScheduler.builder( + () -> isClosing ? Flux.empty() : Flux.fromIterable(this.sessions.values())) + .initialDelay(keepAliveInterval) + .interval(keepAliveInterval) + .build(); + + this.keepAliveScheduler.start(); + } + } + + /** + * Setups the listening SSE connections and message replay. + * + * @param ctx The Jooby context for the incoming request + */ + private Context handleGet(Context ctx) { + if (this.isClosing) { + return SendError.serverIsShuttingDown(ctx); + } + + if (!ctx.accept(TEXT_EVENT_STREAM)) { + return SendError.invalidAcceptHeader(ctx, List.of(TEXT_EVENT_STREAM)); + } + + McpTransportContext transportContext = this.contextExtractor.extract(ctx); + + if (ctx.header(HttpHeaders.MCP_SESSION_ID).isMissing()) { + return SendError.missingSessionId(ctx); + } + + String sessionId = ctx.header(HttpHeaders.MCP_SESSION_ID).value(); + McpStreamableServerSession session = this.sessions.get(sessionId); + + if (session == null) { + return SendError.sessionNotFound(ctx, sessionId); + } + + LOG.debug("Handling GET request for session: {}", sessionId); + + try { + ctx.setResponseType(TEXT_EVENT_STREAM); + return ctx.upgrade( + sse -> { + sse.onClose( + () -> LOG.debug("SSE connection closed by client for session: {}", sessionId)); + + var sessionTransport = new JoobyStreamableMcpSessionTransport(sessionId, sse); + + // Check if this is a replay request + if (ctx.header(HttpHeaders.LAST_EVENT_ID).isPresent()) { + String lastId = ctx.header(HttpHeaders.LAST_EVENT_ID).value(); + + try { + session + .replay(lastId) + .contextWrite( + reactorCtx -> reactorCtx.put(McpTransportContext.KEY, transportContext)) + .toIterable() + .forEach( + message -> { + try { + sessionTransport + .sendMessage(message) + .contextWrite( + reactorCtx -> + reactorCtx.put(McpTransportContext.KEY, transportContext)) + .block(); + } catch (Exception e) { + LOG.error("Failed to replay message: {}", e.getMessage()); + sse.send(SSE_ERROR_EVENT, e.getMessage()); + } + }); + } catch (Exception e) { + LOG.error("Failed to replay messages: {}", e.getMessage()); + sse.send(SSE_ERROR_EVENT, e.getMessage()); + } + } else { + // Establish new listening stream + McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = + session.listeningStream(sessionTransport); + + sse.onClose( + () -> { + LOG.debug("SSE connection has been closed for session: {}", sessionId); + listeningStream.close(); + }); + } + }); + } catch (Exception e) { + LOG.error("Failed to handle GET request for session {}: {}", sessionId, e.getMessage()); + return SendError.internalError(ctx, sessionId); + } + } + + /** + * Handles POST requests for incoming JSON-RPC messages from clients. + * + * @param ctx The Jooby context for the incoming request + */ + private Object handlePost(Context ctx) { + if (this.isClosing) { + return SendError.serverIsShuttingDown(ctx); + } + + if (!ctx.accept(TEXT_EVENT_STREAM) || !ctx.accept(MediaType.json)) { + return SendError.invalidAcceptHeader(ctx, List.of(TEXT_EVENT_STREAM, MediaType.json)); + } + + McpTransportContext transportContext = this.contextExtractor.extract(ctx); + String sessionId = null; + + try { + var body = ctx.body().valueOrNull(); + if (body == null) { + return SendError.error( + ctx, StatusCode.BAD_REQUEST, INVALID_REQUEST, "Request body is missing"); + } + McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(mcpJsonMapper, body); + + // Handle initialization request + if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest + && McpSchema.METHOD_INITIALIZE.equals(jsonrpcRequest.method())) { + + McpSchema.InitializeRequest initRequest = + mcpJsonMapper.convertValue(jsonrpcRequest.params(), McpSchema.InitializeRequest.class); + McpStreamableServerSession.McpStreamableServerSessionInit initObj = + this.sessionFactory.startSession(initRequest); + sessionId = initObj.session().getId(); + this.sessions.put(sessionId, initObj.session()); + + try { + McpSchema.InitializeResult initResult = initObj.initResult().block(); + + ctx.setResponseHeader(HttpHeaders.MCP_SESSION_ID, sessionId); + return new McpSchema.JSONRPCResponse( + McpSchema.JSONRPC_VERSION, jsonrpcRequest.id(), initResult, null); + } catch (Exception e) { + LOG.error("Failed to initialize session: {}", e.getMessage()); + return SendError.internalError(ctx, sessionId); + } + } + + // Handle other messages that require a session + if (ctx.header(HttpHeaders.MCP_SESSION_ID).isMissing()) { + return SendError.missingSessionId(ctx); + } + + sessionId = ctx.header(HttpHeaders.MCP_SESSION_ID).value(); + McpStreamableServerSession session = this.sessions.get(sessionId); + + if (session == null) { + return SendError.sessionNotFound(ctx, sessionId); + } + + if (message instanceof McpSchema.JSONRPCResponse jsonrpcResponse) { + session + .accept(jsonrpcResponse) + .contextWrite(reactorCtx -> reactorCtx.put(McpTransportContext.KEY, transportContext)) + .block(); + return StatusCode.ACCEPTED; + } else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { + session + .accept(jsonrpcNotification) + .contextWrite(reactorCtx -> reactorCtx.put(McpTransportContext.KEY, transportContext)) + .block(); + return StatusCode.ACCEPTED; + } else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { + ctx.setResponseType(TEXT_EVENT_STREAM); + + String finalSessionId = sessionId; + return ctx.upgrade( + sse -> { + sse.onClose( + () -> + LOG.debug( + "Request response stream completed for session: {}", finalSessionId)); + + JoobyStreamableMcpSessionTransport sessionTransport = + new JoobyStreamableMcpSessionTransport(finalSessionId, sse); + + try { + session + .responseStream(jsonrpcRequest, sessionTransport) + .contextWrite( + reactorCtx -> reactorCtx.put(McpTransportContext.KEY, transportContext)) + .block(); + } catch (Exception e) { + LOG.error("Failed to handle request stream: {}", e.getMessage()); + sse.send(SSE_ERROR_EVENT, e.getMessage()); + } + }); + } else { + return SendError.unknownMsgType(ctx, sessionId); + } + } catch (IllegalArgumentException | IOException e) { + LOG.error("Failed to deserialize message: {}", e.getMessage()); + return SendError.msgParseError(ctx, sessionId); + } catch (Exception e) { + LOG.error("Unexpected error occurred while handling message: {}", e.getMessage()); + return SendError.internalError(ctx, sessionId); + } + } + + /** + * Handles DELETE requests for session deletion. + * + * @param ctx The Jooby context for the incoming request + * @return A ServerResponse indicating success or appropriate error status + */ + private Object handleDelete(Context ctx) { + if (this.isClosing) { + return SendError.serverIsShuttingDown(ctx); + } + + if (this.disallowDelete) { + return SendError.deletionNotAllowed(ctx); + } + + McpTransportContext transportContext = this.contextExtractor.extract(ctx); + + if (ctx.header(HttpHeaders.MCP_SESSION_ID).isMissing()) { + return SendError.missingSessionId(ctx); + } + + String sessionId = ctx.header(HttpHeaders.MCP_SESSION_ID).value(); + McpStreamableServerSession session = this.sessions.get(sessionId); + + if (session == null) { + return SendError.sessionNotFound(ctx, sessionId); + } + + try { + session + .delete() + .contextWrite(reactorCtx -> reactorCtx.put(McpTransportContext.KEY, transportContext)) + .block(); + this.sessions.remove(sessionId); + return StatusCode.NO_CONTENT; + } catch (Exception e) { + LOG.error("Failed to delete session {}: {}", sessionId, e.getMessage()); + return SendError.internalError(ctx, sessionId); + } + } + + @Override + public void setSessionFactory(McpStreamableServerSession.Factory sessionFactory) { + this.sessionFactory = sessionFactory; + } + + @Override + public Mono notifyClients(String method, Object params) { + if (this.sessions.isEmpty()) { + LOG.debug("No active sessions to broadcast message to"); + return Mono.empty(); + } + + if (LOG.isDebugEnabled()) { + LOG.debug("Attempting to broadcast message to {} active sessions", this.sessions.size()); + } + + return Mono.fromRunnable( + () -> { + this.sessions.values().parallelStream() + .forEach( + session -> { + try { + session.sendNotification(method, params).block(); + } catch (Exception e) { + LOG.error( + "Failed to send message to session {}: {}", + session.getId(), + e.getMessage()); + } + }); + }); + } + + @Override + public Mono closeGracefully() { + return Mono.fromRunnable( + () -> { + this.isClosing = true; + if (LOG.isDebugEnabled()) { + LOG.debug( + "Initiating graceful shutdown with {} active sessions", this.sessions.size()); + } + + this.sessions.values().parallelStream() + .forEach( + session -> { + try { + session.closeGracefully().block(); + } catch (Exception e) { + LOG.error( + "Failed to close session {}: {}", session.getId(), e.getMessage()); + } + }); + + this.sessions.clear(); + LOG.debug("Graceful shutdown completed"); + }) + .then() + .doOnSuccess( + v -> { + if (this.keepAliveScheduler != null) { + this.keepAliveScheduler.shutdown(); + } + }); + } + + private class JoobyStreamableMcpSessionTransport implements McpStreamableServerTransport { + + private final String sessionId; + private final ServerSentEmitter sse; + private volatile boolean closed = false; + + JoobyStreamableMcpSessionTransport(String sessionId, ServerSentEmitter sse) { + this.sessionId = sessionId; + this.sse = sse; + LOG.debug("Streamable session transport {} initialized with SSE", sessionId); + } + + /** + * Sends a JSON-RPC message to the client through the SSE connection. + * + * @param message The JSON-RPC message to send + * @return A Mono that completes when the message has been sent + */ + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + return sendMessage(message, null); + } + + /** + * Sends a JSON-RPC message to the client through the SSE connection with a specific message ID. + * + * @param message The JSON-RPC message to send + * @param messageId The message ID for SSE event identification + * @return A Mono that completes when the message has been sent + */ + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId) { + return Mono.fromRunnable( + () -> { + try { + if (this.closed) { + LOG.debug("Session {} was closed during message send attempt", this.sessionId); + return; + } + + String jsonText = mcpJsonMapper.writeValueAsString(message); + sse.send( + new ServerSentMessage(jsonText) + .setId(messageId != null ? messageId : this.sessionId) + .setEvent(MESSAGE_EVENT_TYPE)); + LOG.debug("Message sent to session {} with ID {}", this.sessionId, messageId); + } catch (Exception e) { + LOG.error("Failed to send message to session {}: {}", this.sessionId, e.getMessage()); + try { + sse.send(SSE_ERROR_EVENT, e.getMessage()); + } catch (Exception errorEx) { + LOG.error( + "Failed to send error to SSE session {}: {}", + this.sessionId, + errorEx.getMessage()); + } + } + }); + } + + /** + * Converts data from one type to another using the configured McpJsonMapper. + * + * @param data The source data object to convert + * @param typeRef The target type reference + * @param The target type + * @return The converted object of type T + */ + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return mcpJsonMapper.convertValue(data, typeRef); + } + + /** + * Initiates a graceful shutdown of the transport. + * + * @return A Mono that completes when the shutdown is complete + */ + @Override + public Mono closeGracefully() { + return Mono.fromRunnable(this::close); + } + + /** Closes the transport immediately. */ + @Override + public void close() { + try { + if (this.closed) { + LOG.debug("Session transport {} already closed", this.sessionId); + return; + } + + this.closed = true; + sse.close(); + LOG.debug("Successfully closed SSE session {}", sessionId); + } catch (Exception e) { + LOG.warn("Failed to close SSE session {}: {}", sessionId, e.getMessage()); + } + } + } +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/SendError.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/SendError.java new file mode 100644 index 0000000000..997a80c2f8 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/SendError.java @@ -0,0 +1,136 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.mcp.transport; + +import java.util.List; + +import io.jooby.Context; +import io.jooby.MediaType; +import io.jooby.StatusCode; +import io.modelcontextprotocol.spec.HttpHeaders; +import io.modelcontextprotocol.spec.McpSchema; + +/** + * @author kliushnichenko + */ +class SendError { + + static Context serverIsShuttingDown(Context ctx) { + ctx.setResponseCode(StatusCode.SERVICE_UNAVAILABLE); + var err = + err( + new McpSchema.JSONRPCResponse.JSONRPCError( + McpSchema.ErrorCodes.INTERNAL_ERROR, "Server is shutting down", null)); + return send(ctx, err); + } + + static Context invalidAcceptHeader(Context ctx, List acceptedTypes) { + ctx.setResponseCode(StatusCode.BAD_REQUEST); + var err = + err( + new McpSchema.JSONRPCResponse.JSONRPCError( + McpSchema.ErrorCodes.INVALID_REQUEST, + "Invalid Accept header. Expected: %s".formatted(acceptedTypes), + null)); + return send(ctx, err); + } + + static Context missingSessionId(Context ctx) { + ctx.setResponseCode(StatusCode.BAD_REQUEST); + var err = + err( + new McpSchema.JSONRPCResponse.JSONRPCError( + McpSchema.ErrorCodes.INVALID_REQUEST, + "Session ID required in %s header".formatted(HttpHeaders.MCP_SESSION_ID), + null)); + return send(ctx, err); + } + + static Context sessionNotFound(Context ctx, String sessionId) { + ctx.setResponseCode(StatusCode.NOT_FOUND); + var err = + err( + new McpSchema.JSONRPCResponse.JSONRPCError( + McpSchema.ErrorCodes.INVALID_REQUEST, + "Session %s not found".formatted(sessionId), + null)); + return send(ctx, err); + } + + static Context unknownMsgType(Context ctx, String sessionId) { + ctx.setResponseCode(StatusCode.BAD_REQUEST); + var err = + err( + new McpSchema.JSONRPCResponse.JSONRPCError( + McpSchema.ErrorCodes.INVALID_REQUEST, + "Unknown message type. Session ID: %s".formatted(sessionId), + null)); + return send(ctx, err); + } + + static Context msgParseError(Context ctx, String sessionId) { + ctx.setResponseCode(StatusCode.BAD_REQUEST); + var err = + err( + new McpSchema.JSONRPCResponse.JSONRPCError( + McpSchema.ErrorCodes.PARSE_ERROR, + "Invalid message format. Session ID: %s".formatted(sessionId), + null)); + return send(ctx, err); + } + + static Context badRequest(Context ctx, String message) { + ctx.setResponseCode(StatusCode.BAD_REQUEST); + var err = + err( + new McpSchema.JSONRPCResponse.JSONRPCError( + McpSchema.ErrorCodes.INVALID_REQUEST, message, null)); + return send(ctx, err); + } + + static Context deletionNotAllowed(Context ctx) { + ctx.setResponseCode(StatusCode.METHOD_NOT_ALLOWED); + var err = + err( + new McpSchema.JSONRPCResponse.JSONRPCError( + McpSchema.ErrorCodes.INVALID_REQUEST, "Session deletion is not allowed", null)); + return send(ctx, err); + } + + static Context internalError(Context ctx, String sessionId) { + ctx.setResponseCode(StatusCode.SERVER_ERROR); + var err = + err( + new McpSchema.JSONRPCResponse.JSONRPCError( + McpSchema.ErrorCodes.INTERNAL_ERROR, + "Internal Server Error. Session ID: %s".formatted(sessionId), + null)); + return send(ctx, err); + } + + static Context internalError(Context ctx) { + ctx.setResponseCode(StatusCode.SERVER_ERROR); + var err = + err( + new McpSchema.JSONRPCResponse.JSONRPCError( + McpSchema.ErrorCodes.INTERNAL_ERROR, "Internal Server Error", null)); + return send(ctx, err); + } + + public static Context error(Context ctx, StatusCode statusCode, int errCode, String msg) { + ctx.setResponseCode(statusCode); + var err = err(new McpSchema.JSONRPCResponse.JSONRPCError(errCode, msg, null)); + return send(ctx, err); + } + + private static McpSchema.JSONRPCResponse err(McpSchema.JSONRPCResponse.JSONRPCError err) { + return new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, null, null, err); + } + + private static Context send(Context ctx, McpSchema.JSONRPCResponse err) { + return ctx.accept(MediaType.json) ? ctx.render(err) : ctx.send(err.toString()); + } +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/TransportConstants.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/TransportConstants.java new file mode 100644 index 0000000000..bba1748ff8 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/TransportConstants.java @@ -0,0 +1,18 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.mcp.transport; + +import io.jooby.MediaType; + +/** + * @author kliushnichenko + */ +class TransportConstants { + + public static final MediaType TEXT_EVENT_STREAM = MediaType.valueOf("text/event-stream"); + public static final String MESSAGE_EVENT_TYPE = "message"; + public static final String SSE_ERROR_EVENT = "Error"; +} diff --git a/modules/jooby-mcp/src/test/java/io/jooby/mcp/WeatherMcpServer.java b/modules/jooby-mcp/src/test/java/io/jooby/mcp/WeatherMcpServer.java new file mode 100644 index 0000000000..4d82513f45 --- /dev/null +++ b/modules/jooby-mcp/src/test/java/io/jooby/mcp/WeatherMcpServer.java @@ -0,0 +1,177 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.mcp; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; + +import io.jooby.Jooby; +import io.jooby.internal.mcp.MethodInvoker; +import io.jooby.internal.mcp.ToolSpec; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpSchema; + +/** Generated Jooby MCP Server. Do not modify manually. */ +public class WeatherMcpServer implements JoobyMcpServer { + private Jooby app; + + private McpJsonMapper mcpJsonMapper; + + /** Map of tool names to its specification. */ + private final Map tools = new HashMap<>(); + + /** Map of tool names to method invokers. */ + private final Map toolInvokers = new HashMap<>(); + + /** Map of prompt names to its specification. */ + private final Map prompts = new HashMap<>(); + + /** Map of prompt names to method invokers. */ + private final Map promptInvokers = new HashMap<>(); + + /** List of completions reference objects. */ + private final List completions = new ArrayList<>(); + + /** Map of completion key(a composition of _) to method invoker. */ + private final Map> completionInvokers = new HashMap<>(); + + /** List of resources. */ + private final List resources = new ArrayList<>(); + + /** Map of resource URI to method invoker. */ + private final Map> resourceReaders = new HashMap<>(); + + /** List of resource templates. */ + private final List resourceTemplates = new ArrayList<>(); + + /** Map of resource URI template to method invoker. */ + private final Map, Object>> resourceTemplateReaders = + new HashMap<>(); + + /** + * Initialize a new server. + * + * @param app the Jooby application instance + * @param mcpJsonMapper json serializer instance + */ + public void init(final Jooby app, final McpJsonMapper mcpJsonMapper) { + this.app = app; + this.mcpJsonMapper = mcpJsonMapper; + + tools.put( + "get_weather", + ToolSpec.builder() + .name("get_weather") + .inputSchema( + "{\"type\":\"object\",\"properties\":{\"latitude\":{\"type\":\"number\"},\"longitude\":{\"type\":\"number\"}},\"required\":[\"latitude\",\"longitude\"],\"additionalProperties\":false}") + .requiredArguments(List.of("latitude", "longitude")) + .build()); + + toolInvokers.put( + "get_weather", + (args, exchange) -> + app.require(WeatherServer.class) + .getWeather((double) args.get("latitude"), (double) args.get("longitude"))); + } + + /** + * Invokes a tool by name with the provided arguments. + * + * @param toolName the name of the tool to invoke + * @param args the arguments to pass to the tool + * @return the result of the tool invocation + */ + public Object invokeTool( + final String toolName, final Map args, final McpSyncServerExchange exchange) { + MethodInvoker invoker = toolInvokers.get(toolName); + return invoker.invoke(args, exchange); + } + + /** + * Invokes a prompt by name with the provided arguments. + * + * @param promptName the name of the prompt to invoke + * @param args the arguments to pass to the prompt + * @return the result of the prompt invocation + */ + public Object invokePrompt( + final String promptName, + final Map args, + final McpSyncServerExchange exchange) { + MethodInvoker invoker = promptInvokers.get(promptName); + return invoker.invoke(args, exchange); + } + + /** + * Invokes a completion by identifier(prompt or resource name) and argumentName with the provided + * argument value. + * + * @param identifier prompt or resource template name + * @param argumentName the name of an argument in prompt or resource template + * @param input incoming argument value + * @return the result of the completion invocation + */ + public Object invokeCompletion( + final String identifier, final String argumentName, final String input) { + var completionKey = identifier + '_' + argumentName; + var invoker = completionInvokers.get(completionKey); + if (invoker == null) { + return List.of(); + } + return invoker.apply(input); + } + + /** + * Reads a resource by URI + * + * @param uri Resource URI + * @return resource content + */ + public Object readResource(final String uri) { + var reader = resourceReaders.get(uri); + return reader.get(); + } + + /** + * Reads a resource by URI according to template + * + * @param uriTemplate Resource URI template + * @return resource content + */ + public Object readResourceByTemplate(final String uriTemplate, final Map args) { + var reader = resourceTemplateReaders.get(uriTemplate); + return reader.apply(args); + } + + public Map getTools() { + return tools; + } + + public Map getPrompts() { + return prompts; + } + + public List getCompletions() { + return completions; + } + + public List getResources() { + return resources; + } + + public List getResourceTemplates() { + return resourceTemplates; + } + + public String getServerKey() { + return "weather"; + } +} diff --git a/modules/jooby-mcp/src/test/java/io/jooby/mcp/WeatherServer.java b/modules/jooby-mcp/src/test/java/io/jooby/mcp/WeatherServer.java new file mode 100644 index 0000000000..47bf86dc0d --- /dev/null +++ b/modules/jooby-mcp/src/test/java/io/jooby/mcp/WeatherServer.java @@ -0,0 +1,12 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.mcp; + +public class WeatherServer { + public String getWeather(double latitude, double longitude) { + return ""; + } +} From 7b820dbc84fe40c8ee7bc87b3fe62145e50f975c Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Wed, 25 Mar 2026 20:29:26 -0300 Subject: [PATCH 08/37] - build: more fixes on refactor/generator split --- .../java/io/jooby/internal/apt/RestRoute.java | 71 ++++++------------- .../java/io/jooby/internal/apt/TrpcRoute.java | 27 ++++--- .../java/io/jooby/internal/apt/WebRoute.java | 47 ++++++++++++ 3 files changed, 81 insertions(+), 64 deletions(-) diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java index d2818fa539..a271e57796 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java @@ -213,15 +213,14 @@ public List generateHandlerCall(boolean kt) { handlerTypeString = Types.PROJECTED + "<" + returnTypeString + ">"; } - boolean nullable = - methodCallHeader( - kt, - "ctx", - methodName, - buffer, - handlerTypeGenerics, - handlerTypeString, - !method.getThrownTypes().isEmpty()); + methodCallHeader( + kt, + "ctx", + methodName, + buffer, + handlerTypeGenerics, + handlerTypeString, + !method.getThrownTypes().isEmpty()); int controllerIndent = 2; @@ -248,49 +247,27 @@ public List generateHandlerCall(boolean kt) { statusCode, ")", semicolon(kt))); - buffer.add( - statement( - indent(controllerIndent), - "c.", - this.method.getSimpleName(), - paramList.toString(), - semicolon(kt))); + + String call = buildMethodCall(kt, paramList.toString(), false, false); + + buffer.add(statement(indent(controllerIndent), call, semicolon(kt))); buffer.add( statement(indent(controllerIndent), "return ctx.getResponseCode()", semicolon(kt))); } else if (returnType.is("io.jooby.StatusCode")) { + String call = buildMethodCall(kt, paramList.toString(), false, false); + buffer.add( statement( - indent(controllerIndent), - kt ? "val" : "var", - " statusCode = c.", - this.method.getSimpleName(), - paramList.toString(), - semicolon(kt))); + indent(controllerIndent), kt ? "val" : "var", " statusCode = ", call, semicolon(kt))); buffer.add( statement(indent(controllerIndent), "ctx.setResponseCode(statusCode)", semicolon(kt))); buffer.add(statement(indent(controllerIndent), "return statusCode", semicolon(kt))); } else { - var castStr = - isProjectedReturnType - ? "" - : customReturnType.getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); - - // Force cast only if the return type contains Type Variables (like ) - var needsCast = !castStr.isEmpty(); - var kotlinNotEnoughTypeInformation = !castStr.isEmpty() && kt ? "" : ""; - - var call = - of( - "c.", - this.method.getSimpleName(), - kotlinNotEnoughTypeInformation, - paramList.toString()); - - if (needsCast) { - setUncheckedCast(true); - // Use the RAW return type string for the cast, not the modified handler type - call = kt ? call + " as " + returnTypeString : "(" + returnTypeString + ") " + call; - } + + // Leverage shared WebRoute logic for casting and type erasure! + String call = + buildMethodCall(kt, paramList.toString(), isProjectedReturnType, isProjectedReturnType); + boolean nullable = kt && isNullableKotlinReturn(); // 3. ONLY wrap the call if it's a NON-projected type with a projection string if (projection != null && !isProjectedReturnType) { @@ -300,16 +277,12 @@ public List generateHandlerCall(boolean kt) { indent(controllerIndent), "return ", projected, - kt && nullable ? "!!" : "", + nullable ? "!!" : "", semicolon(kt))); } else { buffer.add( statement( - indent(controllerIndent), - "return ", - call, - kt && nullable ? "!!" : "", - semicolon(kt))); + indent(controllerIndent), "return ", call, nullable ? "!!" : "", semicolon(kt))); } } diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRoute.java index 7c5d920b36..4346a1e3fa 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRoute.java @@ -517,7 +517,10 @@ public List generateHandlerCall(boolean kt) { buffer.add( statement(indent(controllerIndent), var(kt), "c = this.factory.apply(ctx)", semicolon(kt))); - var call = CodeBlock.of("c.", this.method.getSimpleName(), paramList.toString()); + + // Leverage shared WebRoute logic for casting and type erasure! + String call = buildMethodCall(kt, paramList.toString(), false, true); + boolean nullable = kt && isNullableKotlinReturn(); if (returnType.isVoid()) { buffer.add(statement(indent(controllerIndent), call, semicolon(kt))); @@ -532,26 +535,20 @@ public List generateHandlerCall(boolean kt) { indent(controllerIndent), "return io.jooby.rpc.trpc.TrpcResponse.of(", call, + nullable ? "!!" : "", // Shared nullability check ")", semicolon(kt))); } if (!parameters.isEmpty()) buffer.add(statement(indent(2), "}")); buffer.add(statement("}", System.lineSeparator())); - return buffer; - } - private String box(String type) { - return switch (type) { - case "int" -> "Integer"; - case "long" -> "Long"; - case "double" -> "Double"; - case "float" -> "Float"; - case "boolean" -> "Boolean"; - case "byte" -> "Byte"; - case "short" -> "Short"; - case "char" -> "Character"; - default -> type; - }; + // Shared Unchecked Cast suppression + if (isUncheckedCast()) { + if (kt) buffer.addFirst(statement("@Suppress(\"UNCHECKED_CAST\")")); + else buffer.addFirst(statement("@SuppressWarnings(\"unchecked\")")); + } + + return buffer; } } diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRoute.java index 688b3bd270..d0289c311f 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRoute.java @@ -10,9 +10,11 @@ import java.util.List; import java.util.Objects; +import java.util.Set; import java.util.stream.Stream; import javax.lang.model.element.ExecutableElement; +import javax.lang.model.type.TypeKind; import javax.lang.model.type.TypeMirror; import javax.lang.model.type.WildcardType; @@ -136,4 +138,49 @@ public List getJavaMethodSignature(boolean kt) { .map(it -> it + clazz(kt)) .toList(); } + + public boolean isNullableKotlinReturn() { + return method.getAnnotationMirrors().stream() + .map(javax.lang.model.element.AnnotationMirror::getAnnotationType) + .map(java.util.Objects::toString) + .anyMatch(AnnotationSupport.NULLABLE); + } + + protected String buildMethodCall( + boolean kt, String paramList, boolean preventCast, boolean isRpcWrapper) { + var customReturnType = getReturnType(); + var castStr = + preventCast ? "" : customReturnType.getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); + + // Force cast ONLY if there's a Type Variable ( -> ) + // OR if it's an RPC wrapper where strict Java generics conflict with Kotlin's nullable generics + var needsCast = + !castStr.isEmpty() + || (kt && !preventCast && isRpcWrapper && !customReturnType.getArguments().isEmpty()); + + var kotlinNotEnoughTypeInformation = !castStr.isEmpty() && kt ? "" : ""; + + var call = "c." + this.method.getSimpleName() + kotlinNotEnoughTypeInformation + paramList; + + if (needsCast) { + setUncheckedCast(true); + var returnTypeString = CodeBlock.type(kt, customReturnType.toString()); + call = kt ? call + " as " + returnTypeString : "(" + returnTypeString + ") " + call; + } + return call; + } + + protected String box(String type) { + return switch (type) { + case "int" -> "Integer"; + case "long" -> "Long"; + case "double" -> "Double"; + case "float" -> "Float"; + case "boolean" -> "Boolean"; + case "byte" -> "Byte"; + case "short" -> "Short"; + case "char" -> "Character"; + default -> type; + }; + } } From d8e2047a7adb18ee043496f4ec5bc28e5cbcd828 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Wed, 25 Mar 2026 20:38:31 -0300 Subject: [PATCH 09/37] - build fix: all tests are good now --- .../java/io/jooby/internal/apt/TrpcRoute.java | 89 ++++++++++++++++++- .../jooby/i3863/AbstractTrpcProtocolTest.java | 2 +- 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRoute.java index 4346a1e3fa..939369edde 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRoute.java @@ -519,10 +519,93 @@ public List generateHandlerCall(boolean kt) { statement(indent(controllerIndent), var(kt), "c = this.factory.apply(ctx)", semicolon(kt))); // Leverage shared WebRoute logic for casting and type erasure! + // Pass 'true' for isRpcWrapper so it safely casts List to List String call = buildMethodCall(kt, paramList.toString(), false, true); boolean nullable = kt && isNullableKotlinReturn(); - if (returnType.isVoid()) { + if (reactive != null) { + if (isReactiveVoid) { + var handler = reactive.handlerType(); + if (handler.contains("Reactor")) { + buffer.add( + statement( + indent(controllerIndent), + "return ", + call, + ".then(reactor.core.publisher.Mono.just(io.jooby.rpc.trpc.TrpcResponse.empty()))", + semicolon(kt))); + } else if (handler.contains("Mutiny")) { + buffer.add( + statement( + indent(controllerIndent), + "return ", + call, + ".replaceWith(io.jooby.rpc.trpc.TrpcResponse.empty())", + semicolon(kt))); + } else if (handler.contains("ReactiveSupport")) { + buffer.add( + statement( + indent(controllerIndent), + "return ", + call, + ".thenApply(x -> io.jooby.rpc.trpc.TrpcResponse.empty())", + semicolon(kt))); + } else if (handler.contains("Reactivex")) { + buffer.add( + statement( + indent(controllerIndent), + "return ", + call, + ".toSingleDefault(io.jooby.rpc.trpc.TrpcResponse.empty())", + semicolon(kt))); + } else { + buffer.add( + statement( + indent(controllerIndent), + "return ", + call, + ".map(x -> io.jooby.rpc.trpc.TrpcResponse.empty())", + semicolon(kt))); + } + } else { + var handler = reactive.handlerType(); + if (kt) { + buffer.add( + statement( + indent(controllerIndent), + "return ", + call, + ".map { io.jooby.rpc.trpc.TrpcResponse.of(it) }")); + } else { + if (handler.contains("ReactiveSupport")) { + buffer.add( + statement( + indent(controllerIndent), + "return ", + call, + ".thenApply(io.jooby.rpc.trpc.TrpcResponse::of)", + semicolon(kt))); + } else if (handler.contains("Mutiny")) { + buffer.add( + statement( + indent(controllerIndent), + "return ", + call, + ".onItem().transform(io.jooby.rpc.trpc.TrpcResponse::of)", + semicolon(kt))); + } else { + // Reactor (Mono), RxJava (Single), etc. + buffer.add( + statement( + indent(controllerIndent), + "return ", + call, + ".map(io.jooby.rpc.trpc.TrpcResponse::of)", + semicolon(kt))); + } + } + } + } else if (returnType.isVoid()) { buffer.add(statement(indent(controllerIndent), call, semicolon(kt))); buffer.add( statement( @@ -543,9 +626,9 @@ public List generateHandlerCall(boolean kt) { if (!parameters.isEmpty()) buffer.add(statement(indent(2), "}")); buffer.add(statement("}", System.lineSeparator())); - // Shared Unchecked Cast suppression + // Suppress both UNCHECKED_CAST and USELESS_CAST to keep the Kotlin compiler perfectly quiet if (isUncheckedCast()) { - if (kt) buffer.addFirst(statement("@Suppress(\"UNCHECKED_CAST\")")); + if (kt) buffer.addFirst(statement("@Suppress(\"UNCHECKED_CAST\", \"USELESS_CAST\")")); else buffer.addFirst(statement("@SuppressWarnings(\"unchecked\")")); } diff --git a/tests/src/test/java/io/jooby/i3863/AbstractTrpcProtocolTest.java b/tests/src/test/java/io/jooby/i3863/AbstractTrpcProtocolTest.java index 7c81b20d30..71776fe793 100644 --- a/tests/src/test/java/io/jooby/i3863/AbstractTrpcProtocolTest.java +++ b/tests/src/test/java/io/jooby/i3863/AbstractTrpcProtocolTest.java @@ -27,7 +27,7 @@ public abstract class AbstractTrpcProtocolTest { private void setupApp(Jooby app) { installJsonEngine(app); app.install(new TrpcModule()); - app.mvc(new MovieService_()); + app.mvc(new MovieServiceTrpc_()); } @ServerTest From b5dca0ff73bc31bb92843771e8d4ae19386d915f Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Thu, 26 Mar 2026 07:30:16 -0300 Subject: [PATCH 10/37] - build: code clean up --- .../java/io/jooby/apt/JoobyProcessor.java | 227 +- .../io/jooby/internal/apt/HttpMethod.java | 16 +- .../io/jooby/internal/apt/JsonRpcRoute.java | 56 +- .../io/jooby/internal/apt/JsonRpcRouter.java | 226 +- .../java/io/jooby/internal/apt/McpRouter.java | 2 +- .../java/io/jooby/internal/apt/MvcRoute.java | 1937 ----------------- .../java/io/jooby/internal/apt/MvcRouter.java | 1254 ----------- .../java/io/jooby/internal/apt/RestRoute.java | 63 +- .../io/jooby/internal/apt/RestRouter.java | 25 +- .../java/io/jooby/internal/apt/TrpcRoute.java | 93 +- .../io/jooby/internal/apt/TrpcRouter.java | 6 +- .../java/io/jooby/internal/apt/WebRoute.java | 17 +- .../java/io/jooby/internal/apt/WebRouter.java | 2 +- .../java/io/jooby/apt/ProcessorRunner.java | 2 +- 14 files changed, 237 insertions(+), 3689 deletions(-) delete mode 100644 modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java delete mode 100644 modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java diff --git a/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java b/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java index 85732607de..ee649affab 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java +++ b/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java @@ -16,13 +16,11 @@ import java.nio.file.Paths; import java.util.*; import java.util.function.BiConsumer; -import java.util.stream.Collectors; import java.util.stream.Stream; import javax.annotation.processing.*; import javax.lang.model.SourceVersion; import javax.lang.model.element.*; -import javax.lang.model.type.DeclaredType; import javax.tools.Diagnostic; import javax.tools.JavaFileObject; import javax.tools.SimpleJavaFileObject; @@ -132,50 +130,55 @@ public boolean process(Set annotations, RoundEnvironment context.getRouters().forEach(it -> context.debug(" %s", it.getGeneratedType())); return false; } else { - // 1. Discover all unique Controller classes - Set controllers = findControllers(annotations, roundEnv); + // Discover all unique Controller classes + var controllers = findControllers(annotations, roundEnv); - // 2. Factory Pattern: Build specific routers for each class based on method annotations + // Factory Pattern: Build specific routers for each class based on method annotations List> activeRouters = new ArrayList<>(); - for (TypeElement controller : controllers) { + for (var controller : controllers) { if (controller.getModifiers().contains(Modifier.ABSTRACT)) continue; // These factory methods will scan the class methods and return a populated router // if it finds relevant annotations (@GET for Rest, @McpTool for MCP, etc.) // We will implement these factories inside the respective Router classes. - RestRouter restRouter = RestRouter.parse(context, controller); - if (!restRouter.isEmpty()) activeRouters.add(restRouter); + var restRouter = RestRouter.parse(context, controller); + if (!restRouter.isEmpty()) { + activeRouters.add(restRouter); + } - JsonRpcRouter jsonRpcRouter = JsonRpcRouter.parse(context, controller); - if (!jsonRpcRouter.isEmpty()) activeRouters.add(jsonRpcRouter); + var jsonRpcRouter = JsonRpcRouter.parse(context, controller); + if (!jsonRpcRouter.isEmpty()) { + activeRouters.add(jsonRpcRouter); + } - McpRouter mcpRouter = McpRouter.parse(context, controller); - if (!mcpRouter.isEmpty()) activeRouters.add(mcpRouter); + var mcpRouter = McpRouter.parse(context, controller); + if (!mcpRouter.isEmpty()) { + activeRouters.add(mcpRouter); + } - TrpcRouter trpcRouter = TrpcRouter.parse(context, controller); - if (!trpcRouter.isEmpty()) activeRouters.add(trpcRouter); + var trpcRouter = TrpcRouter.parse(context, controller); + if (!trpcRouter.isEmpty()) { + activeRouters.add(trpcRouter); + } } verifyBeanValidationDependency(activeRouters); - // 3. Generate Code Iteratively! - for (WebRouter router : activeRouters) { + // Generate Code Iteratively! + for (var router : activeRouters) { try { - context.add(router); // Track for processingOver output - - String sourceCode = router.getSourceCode(null); - if (sourceCode != null) { - String sourceLocation = router.getGeneratedFilename(); - String generatedType = router.getGeneratedType(); + context.add(router); - onGeneratedSource(generatedType, toJavaFileObject(sourceLocation, sourceCode)); - context.debug("router %s: %s", router.getTargetType(), generatedType); + var sourceCode = router.toSourceCode(null); + var sourceLocation = router.getGeneratedFilename(); + var generatedType = router.getGeneratedType(); - writeSource( - router.isKt(), generatedType, sourceLocation, sourceCode, router.getTargetType()); - } + onGeneratedSource(generatedType, toJavaFileObject(sourceLocation, sourceCode)); + context.debug("router %s: %s", router.getTargetType(), generatedType); + writeSource( + router.isKt(), generatedType, sourceLocation, sourceCode, router.getTargetType()); } catch (IOException cause) { throw new RuntimeException("Unable to generate: " + router.getTargetType(), cause); } @@ -194,7 +197,8 @@ private Set findControllers( Set controllers = new LinkedHashSet<>(); for (var annotation : annotations) { for (var element : roundEnv.getElementsAnnotatedWith(annotation)) { - if (element instanceof TypeElement typeElement) { + if (element instanceof TypeElement typeElement + && !typeElement.getModifiers().contains(Modifier.ABSTRACT)) { controllers.add(typeElement); } else if (element instanceof ExecutableElement method) { controllers.add((TypeElement) method.getEnclosingElement()); @@ -262,142 +266,16 @@ public String toString() { protected void onGeneratedSource(String className, JavaFileObject source) {} - private Map buildRouteRegistry( - Set annotations, RoundEnvironment roundEnv) { - Map registry = new LinkedHashMap<>(); - - for (var annotation : annotations) { - context.debug("found annotation: %s", annotation); - var elements = roundEnv.getElementsAnnotatedWith(annotation); - // Element could be Class or Method, bc @Path can be applied to both of them - // Also we need to expand lookup to external jars see #2486 - for (var element : elements) { - context.debug(" %s", element); - if (element instanceof TypeElement typeElement) { - // FORCE INIT: Ensures MvcRouter constructor executes our JsonRpc class-level rules - registry.computeIfAbsent(typeElement, type -> new MvcRouter(context, type)); - buildRouteRegistry(registry, typeElement); - } else if (element instanceof ExecutableElement method) { - TypeElement typeElement = (TypeElement) method.getEnclosingElement(); - // FORCE INIT - registry.computeIfAbsent(typeElement, type -> new MvcRouter(context, type)); - buildRouteRegistry(registry, typeElement); - } - } - } - - // Remove all abstract router - var abstractTypes = - registry.entrySet().stream() - .filter(it -> it.getValue().isAbstract()) - .map(Map.Entry::getKey) - .collect(Collectors.toSet()); - abstractTypes.forEach(registry::remove); - - // Generate unique method name by router - for (var router : registry.values()) { - // Split routes by their target generated classes to avoid false collisions - var restAndTrpcRoutes = router.getRoutes().stream().filter(r -> !r.isJsonRpc()).toList(); - - var rpcRoutes = router.getRoutes().stream().filter(MvcRoute::isJsonRpc).toList(); - - resolveGeneratedNames(restAndTrpcRoutes); - resolveGeneratedNames(rpcRoutes); - } - return registry; - } - - private void resolveGeneratedNames(List routes) { - // Group by the actual target method name in the generated class - var grouped = - routes.stream() - .collect( - Collectors.groupingBy( - route -> { - String baseName = route.getMethodName(); - return route.isTrpc() - ? "trpc" - + Character.toUpperCase(baseName.charAt(0)) - + baseName.substring(1) - : baseName; - })); - - for (var overloads : grouped.values()) { - if (overloads.size() == 1) { - // No conflict in this specific output file, use the clean original name - overloads.get(0).setGeneratedName(overloads.get(0).getMethodName()); - } else { - // Conflict detected: generate names based on parameter types - for (var route : overloads) { - var paramsString = - route.getRawParameterTypes(true).stream() - .map(it -> it.substring(Math.max(0, it.lastIndexOf(".") + 1))) - .map(it -> Character.toUpperCase(it.charAt(0)) + it.substring(1)) - .collect(Collectors.joining()); - - // A 0-arg method gets exactly the base name. - // Methods with args get the base name + their parameter types. - route.setGeneratedName(route.getMethodName() + paramsString); - } - } - } - } - - /** - * Scan routes from basType and any super class of it. It saves all route method found in current - * type or super (inherited). Routes method from super types are also saved. - * - *

Abstract route method are ignored. - * - * @param registry Route registry. - * @param currentType Base type. - */ - private void buildRouteRegistry(Map registry, TypeElement currentType) { - for (TypeElement superType : context.superTypes(currentType)) { - // collect all declared methods - superType.getEnclosedElements().stream() - .filter(ExecutableElement.class::isInstance) - .map(ExecutableElement.class::cast) - .forEach( - method -> { - if (method.getModifiers().contains(Modifier.ABSTRACT)) { - context.debug("ignoring abstract method: %s %s", superType, method); - } else { - method.getAnnotationMirrors().stream() - .map(AnnotationMirror::getAnnotationType) - .map(DeclaredType::asElement) - .filter(TypeElement.class::isInstance) - .map(TypeElement.class::cast) - .filter(HttpMethod::hasAnnotation) - .forEach( - annotation -> { - Stream.of(currentType, superType) - .distinct() - .forEach( - routerClass -> - registry - .computeIfAbsent( - routerClass, type -> new MvcRouter(context, type)) - .put(annotation, method)); - }); - } - }); - if (!currentType.equals(superType)) { - // edge-case #1: when a controller has no method and extends another class which has. - // edge-case #2: some odd usage a controller could be empty. - // See https://github.com/jooby-project/jooby/issues/3656 - if (registry.containsKey(superType)) { - registry.computeIfAbsent(currentType, key -> new MvcRouter(key, registry.get(superType))); - } - } - } - } - @Override public Set getSupportedAnnotationTypes() { var supportedTypes = new HashSet(); supportedTypes.addAll(HttpPath.PATH.getAnnotations()); supportedTypes.addAll(HttpMethod.annotations()); + // Add Rcp annotations + supportedTypes.add("io.jooby.annotation.Trpc"); + supportedTypes.add("io.jooby.annotation.Trpc.Mutation"); + supportedTypes.add("io.jooby.annotation.Trpc.Query"); + supportedTypes.add("io.jooby.annotation.JsonRpc"); // Add MCP Annotations supportedTypes.add("io.jooby.annotation.McpTool"); supportedTypes.add("io.jooby.annotation.McpPrompt"); @@ -428,37 +306,6 @@ public Set getSupportedOptions() { return options; } - /** - * Throws any throwable 'sneakily' - you don't need to catch it, nor declare that you throw it - * onwards. The exception is still thrown - javac will just stop whining about it. - * - *

Example usage: - * - *

public void run() {
-   * throw sneakyThrow(new IOException("You don't need to catch me!"));
-   * }
- * - *

NB: The exception is not wrapped, ignored, swallowed, or redefined. The JVM actually does - * not know or care about the concept of a 'checked exception'. All this method does is hide the - * act of throwing a checked exception from the java compiler. - * - *

Note that this method has a return type of {@code RuntimeException}; it is advised you - * always call this method as argument to the {@code throw} statement to avoid compiler errors - * regarding no return statement and similar problems. This method won't of course return an - * actual {@code RuntimeException} - it never returns, it always throws the provided exception. - * - * @param x The throwable to throw without requiring you to catch its type. - * @return A dummy RuntimeException; this method never returns normally, it always throws - * an exception! - */ - public static RuntimeException propagate(final Throwable x) { - if (x == null) { - throw new NullPointerException("x"); - } - - return sneakyThrow0(x); - } - /** * Make a checked exception un-checked and rethrow it. * diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java index 2cd7b3e23e..6c4052e61b 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java @@ -27,21 +27,7 @@ public enum HttpMethod implements AnnotationSupport { OPTIONS, PATCH, POST, - PUT, - // Special - tRPC( - List.of( - "io.jooby.annotation.Trpc", - "io.jooby.annotation.Trpc.Mutation", - "io.jooby.annotation.Trpc.Query")), - JSON_RPC(List.of("io.jooby.annotation.JsonRpc")), - MCP( - List.of( - "io.jooby.annotation.McpTool", - "io.jooby.annotation.McpCompletion", - "io.jooby.annotation.McpPrompt", - "io.jooby.annotation.McpResource", - "io.jooby.annotation.McpServer")); + PUT; private final List annotations; diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRoute.java index c39d6aef67..fce41d9730 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRoute.java @@ -55,7 +55,7 @@ public List generateJsonRpcDispatchCase(boolean kt) { buffer.addAll(generateRpcParameter(kt, paramList::add)); - int callIndent = needsReader ? 10 : 8; + var callIndent = needsReader ? 10 : 8; var call = CodeBlock.of("c.", getMethodName(), paramList.toString()); if (returnType.isVoid()) { @@ -78,7 +78,7 @@ private List generateRpcParameter(boolean kt, Consumer arguments int baseIndent = 10; for (var parameter : parameters) { - var paramenterName = parameter.getName(); + var parameterName = parameter.getName(); var type = type(kt, parameter.getType().toString()); boolean isNullable = parameter.isNullable(kt); @@ -106,26 +106,26 @@ private List generateRpcParameter(boolean kt, Consumer arguments statement( indent(baseIndent), "val ", - paramenterName, + parameterName, " = if (reader.nextIsNull(", - string(paramenterName), + string(parameterName), ")) null else reader.", readName, "(", - string(paramenterName), + string(parameterName), ")")); } else { statements.add( statement( indent(baseIndent), var(kt), - paramenterName, + parameterName, " = reader.nextIsNull(", - string(paramenterName), + string(parameterName), ") ? null : reader.", readName, "(", - string(paramenterName), + string(parameterName), ")", semicolon(kt))); } @@ -134,15 +134,15 @@ private List generateRpcParameter(boolean kt, Consumer arguments statement( indent(baseIndent), var(kt), - paramenterName, + parameterName, " = reader.", readName, "(", - string(paramenterName), + string(parameterName), ")", semicolon(kt))); } - arguments.accept(paramenterName); + arguments.accept(parameterName); break; default: if (kt) { @@ -150,7 +150,7 @@ private List generateRpcParameter(boolean kt, Consumer arguments statement( indent(baseIndent), "val ", - paramenterName, + parameterName, "Decoder: ", decoderInterface, "<", @@ -164,24 +164,24 @@ private List generateRpcParameter(boolean kt, Consumer arguments statement( indent(baseIndent), "val ", - paramenterName, + parameterName, " = if (reader.nextIsNull(", - string(paramenterName), + string(parameterName), ")) null else reader.nextObject(", - string(paramenterName), + string(parameterName), ", ", - paramenterName, + parameterName, "Decoder)")); } else { statements.add( statement( indent(baseIndent), "val ", - paramenterName, + parameterName, " = reader.nextObject(", - string(paramenterName), + string(parameterName), ", ", - paramenterName, + parameterName, "Decoder)", semicolon(kt))); } @@ -193,7 +193,7 @@ private List generateRpcParameter(boolean kt, Consumer arguments "<", type, "> ", - paramenterName, + parameterName, "Decoder = parser.decoder(", parameter.getType().toSourceCode(kt), ")", @@ -204,13 +204,13 @@ private List generateRpcParameter(boolean kt, Consumer arguments indent(baseIndent), parameter.getType().toString(), " ", - paramenterName, + parameterName, " = reader.nextIsNull(", - string(paramenterName), + string(parameterName), ") ? null : reader.nextObject(", - string(paramenterName), + string(parameterName), ", ", - paramenterName, + parameterName, "Decoder)", semicolon(kt))); } else { @@ -219,16 +219,16 @@ private List generateRpcParameter(boolean kt, Consumer arguments indent(baseIndent), parameter.getType().toString(), " ", - paramenterName, + parameterName, " = reader.nextObject(", - string(paramenterName), + string(parameterName), ", ", - paramenterName, + parameterName, "Decoder)", semicolon(kt))); } } - arguments.accept(paramenterName); + arguments.accept(parameterName); break; } } diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRouter.java index bbc0bfbcf4..62cde61120 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRouter.java @@ -25,12 +25,12 @@ public JsonRpcRouter(MvcContext context, TypeElement clazz) { } public static JsonRpcRouter parse(MvcContext context, TypeElement controller) { - JsonRpcRouter router = new JsonRpcRouter(context, controller); - var classJsonRpcAnno = + var router = new JsonRpcRouter(context, controller); + var classAnnotation = AnnotationSupport.findAnnotationByName(controller, "io.jooby.annotation.JsonRpc"); - List explicitlyAnnotated = new ArrayList<>(); - List allPublicMethods = new ArrayList<>(); + var explicitlyAnnotated = new ArrayList(); + var allPublicMethods = new ArrayList(); for (var enclosed : controller.getEnclosedElements()) { if (enclosed.getKind() == ElementKind.METHOD) { @@ -40,7 +40,7 @@ public static JsonRpcRouter parse(MvcContext context, TypeElement controller) { if (modifiers.contains(Modifier.PUBLIC) && !modifiers.contains(Modifier.STATIC) && !modifiers.contains(Modifier.ABSTRACT)) { - String methodName = method.getSimpleName().toString(); + var methodName = method.getSimpleName().toString(); if (methodName.equals("toString") || methodName.equals("hashCode") || methodName.equals("equals") @@ -57,12 +57,12 @@ public static JsonRpcRouter parse(MvcContext context, TypeElement controller) { if (!explicitlyAnnotated.isEmpty()) { for (var method : explicitlyAnnotated) { - JsonRpcRoute route = new JsonRpcRoute(router, method); + var route = new JsonRpcRoute(router, method); router.routes.put(route.getMethodName(), route); } - } else if (classJsonRpcAnno != null) { + } else if (classAnnotation != null) { for (var method : allPublicMethods) { - JsonRpcRoute route = new JsonRpcRoute(router, method); + var route = new JsonRpcRoute(router, method); router.routes.put(route.getMethodName(), route); } } @@ -71,10 +71,10 @@ public static JsonRpcRouter parse(MvcContext context, TypeElement controller) { @Override public String getGeneratedType() { - return context.generateRouterName(getTargetType().getQualifiedName().toString() + "Rpc"); + return context.generateRouterName(getTargetType().getQualifiedName() + "Rpc"); } - public String getJsonRpcNamespace() { + private String getJsonRpcNamespace() { var annotation = AnnotationSupport.findAnnotationByName(clazz, "io.jooby.annotation.JsonRpc"); if (annotation != null) { return AnnotationSupport.findAnnotationValue(annotation, VALUE).stream() @@ -85,9 +85,7 @@ public String getJsonRpcNamespace() { } @Override - public String getSourceCode(Boolean generateKotlin) throws IOException { - if (isEmpty()) return null; - + public String toSourceCode(Boolean generateKotlin) throws IOException { boolean kt = generateKotlin == Boolean.TRUE || isKt(); var generateTypeName = getTargetType().getSimpleName().toString(); var generatedClass = getGeneratedType().substring(getGeneratedType().lastIndexOf('.') + 1); @@ -105,159 +103,103 @@ public String getSourceCode(Boolean generateKotlin) throws IOException { buffer.setLength(0); List fullMethods = new ArrayList<>(); - for (JsonRpcRoute route : getRoutes()) { - String routeName = route.getJsonRpcMethodName(); + for (var route : getRoutes()) { + var routeName = route.getJsonRpcMethodName(); fullMethods.add(namespace.isEmpty() ? routeName : namespace + "." + routeName); } - String methodListString = + var methodListString = fullMethods.stream().map(m -> "\"" + m + "\"").collect(Collectors.joining(", ")); if (kt) { - buffer.append(indent(4)).append("@Throws(Exception::class)").append(System.lineSeparator()); - buffer - .append(indent(4)) - .append("override fun install(app: io.jooby.Jooby) {") - .append(System.lineSeparator()); - buffer - .append(indent(6)) - .append("app.services.listOf(io.jooby.rpc.jsonrpc.JsonRpcService::class.java).add(this)") - .append(System.lineSeparator()); - buffer - .append(indent(4)) - .append("}") - .append(System.lineSeparator()) - .append(System.lineSeparator()); - - buffer - .append(indent(4)) - .append("override fun getMethods(): List {") - .append(System.lineSeparator()); - buffer - .append(indent(6)) - .append("return listOf(") - .append(methodListString) - .append(")") - .append(System.lineSeparator()); - buffer - .append(indent(4)) - .append("}") - .append(System.lineSeparator()) - .append(System.lineSeparator()); - - buffer - .append(indent(4)) - .append( + buffer.append(statement(indent(4), "@Throws(Exception::class)")); + buffer.append(statement(indent(4), "override fun install(app: io.jooby.Jooby) {")); + buffer.append( + statement( + indent(6), + "app.services.listOf(io.jooby.rpc.jsonrpc.JsonRpcService::class.java).add(this)")); + buffer.append(statement(indent(4), "}", System.lineSeparator())); + + buffer.append(statement(indent(4), "override fun getMethods(): List {")); + buffer.append(statement(indent(6), "return listOf(", methodListString, ")")); + buffer.append(statement(indent(4), "}", System.lineSeparator())); + + buffer.append( + statement( + indent(4), "override fun execute(ctx: io.jooby.Context, req:" - + " io.jooby.rpc.jsonrpc.JsonRpcRequest): Any? {") - .append(System.lineSeparator()); - buffer.append(indent(6)).append("val c = factory.apply(ctx)").append(System.lineSeparator()); - buffer.append(indent(6)).append("val method = req.method").append(System.lineSeparator()); - buffer - .append(indent(6)) - .append("val parser = ctx.require(io.jooby.rpc.jsonrpc.JsonRpcParser::class.java)") - .append(System.lineSeparator()); - buffer.append(indent(6)).append("return when(method) {").append(System.lineSeparator()); + + " io.jooby.rpc.jsonrpc.JsonRpcRequest): Any? {")); + buffer.append(statement(indent(6), "val c = factory.apply(ctx)")); + buffer.append(statement(indent(6), "val method = req.method")); + buffer.append( + statement( + indent(6), + "val parser = ctx.require(io.jooby.rpc.jsonrpc.JsonRpcParser::class.java)")); + buffer.append(statement(indent(6), "return when(method) {")); for (int i = 0; i < getRoutes().size(); i++) { - buffer - .append(indent(8)) - .append("\"") - .append(fullMethods.get(i)) - .append("\" -> {") - .append(System.lineSeparator()); + buffer.append(statement(indent(8), string(fullMethods.get(i)), " -> {")); getRoutes().get(i).generateJsonRpcDispatchCase(true).forEach(buffer::append); - buffer.append(indent(8)).append("}").append(System.lineSeparator()); + buffer.append(statement(indent(8), "}")); } - buffer - .append(indent(8)) - .append( + buffer.append( + statement( + indent(8), "else -> throw" - + " io.jooby.rpc.jsonrpc.JsonRpcException(io.jooby.rpc.jsonrpc.JsonRpcErrorCode.METHOD_NOT_FOUND," - + " \"Method not found: $method\")") - .append(System.lineSeparator()); - buffer.append(indent(6)).append("}").append(System.lineSeparator()); - buffer.append(indent(4)).append("}").append(System.lineSeparator()); + + " io.jooby.rpc.jsonrpc.JsonRpcException(io.jooby.rpc.jsonrpc.JsonRpcErrorCode.METHOD_NOT_FOUND,", + string("Method not found: $method"), + ")")); + buffer.append(statement(indent(6), "}")); + buffer.append(statement(indent(4), "}")); } else { - buffer.append(indent(4)).append("@Override").append(System.lineSeparator()); - buffer - .append(indent(4)) - .append("public void install(io.jooby.Jooby app) throws Exception {") - .append(System.lineSeparator()); - buffer - .append(indent(6)) - .append("app.getServices().listOf(io.jooby.rpc.jsonrpc.JsonRpcService.class).add(this);") - .append(System.lineSeparator()); - buffer - .append(indent(4)) - .append("}") - .append(System.lineSeparator()) - .append(System.lineSeparator()); - - buffer.append(indent(4)).append("@Override").append(System.lineSeparator()); - buffer - .append(indent(4)) - .append("public java.util.List getMethods() {") - .append(System.lineSeparator()); - buffer - .append(indent(6)) - .append("return java.util.List.of(") - .append(methodListString) - .append(");") - .append(System.lineSeparator()); - buffer - .append(indent(4)) - .append("}") - .append(System.lineSeparator()) - .append(System.lineSeparator()); - - buffer.append(indent(4)).append("@Override").append(System.lineSeparator()); - buffer - .append(indent(4)) - .append( + buffer.append(statement(indent(4), "@Override")); + buffer.append( + statement(indent(4), "public void install(io.jooby.Jooby app) throws Exception {")); + buffer.append( + statement( + indent(6), + "app.getServices().listOf(io.jooby.rpc.jsonrpc.JsonRpcService.class).add(this);")); + buffer.append(statement(indent(4), "}", System.lineSeparator())); + + buffer.append(statement(indent(4), "@Override")); + buffer.append(statement(indent(4), "public java.util.List getMethods() {")); + buffer.append(statement(indent(6), "return java.util.List.of(", methodListString, ");")); + buffer.append(statement(indent(4), "}", System.lineSeparator())); + + buffer.append(statement(indent(4), "@Override")); + buffer.append( + statement( + indent(4), "public Object execute(io.jooby.Context ctx, io.jooby.rpc.jsonrpc.JsonRpcRequest req)" - + " throws Exception {") - .append(System.lineSeparator()); - buffer - .append(indent(6)) - .append(generateTypeName) - .append(" c = factory.apply(ctx);") - .append(System.lineSeparator()); - buffer - .append(indent(6)) - .append("String method = req.getMethod();") - .append(System.lineSeparator()); - buffer - .append(indent(6)) - .append( + + " throws Exception {")); + buffer.append(statement(indent(6), "var c = factory.apply(ctx);")); + buffer.append(statement(indent(6), "var method = req.getMethod();")); + buffer.append( + statement( + indent(6), "io.jooby.rpc.jsonrpc.JsonRpcParser parser =" - + " ctx.require(io.jooby.rpc.jsonrpc.JsonRpcParser.class);") - .append(System.lineSeparator()); - buffer.append(indent(6)).append("switch(method) {").append(System.lineSeparator()); + + " ctx.require(io.jooby.rpc.jsonrpc.JsonRpcParser.class);")); + buffer.append(statement(indent(6), "switch(method) {")); for (int i = 0; i < getRoutes().size(); i++) { - buffer - .append(indent(8)) - .append("case \"") - .append(fullMethods.get(i)) - .append("\": {") - .append(System.lineSeparator()); + buffer.append(statement(indent(8), "case ", string(fullMethods.get(i)), ": {")); getRoutes().get(i).generateJsonRpcDispatchCase(false).forEach(buffer::append); - buffer.append(indent(8)).append("}").append(System.lineSeparator()); + buffer.append(statement(indent(8), "}")); } - buffer.append(indent(8)).append("default:").append(System.lineSeparator()); - buffer - .append(indent(10)) - .append( + buffer.append(statement(indent(8), "default:")); + buffer.append( + statement( + indent(10), "throw new" + " io.jooby.rpc.jsonrpc.JsonRpcException(io.jooby.rpc.jsonrpc.JsonRpcErrorCode.METHOD_NOT_FOUND," - + " \"Method not found: \" + method);") - .append(System.lineSeparator()); - buffer.append(indent(6)).append("}").append(System.lineSeparator()); - buffer.append(indent(4)).append("}").append(System.lineSeparator()); + + " ", + string("Method not found:"), + " + method);")); + buffer.append(statement(indent(6), "}")); + buffer.append(statement(indent(4), "}")); } return template diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java index fcfebd5c44..9afbfce844 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java @@ -83,7 +83,7 @@ private String findTargetMethodName(String ref) { } @Override - public String getSourceCode(Boolean generateKotlin) throws IOException { + public String toSourceCode(Boolean generateKotlin) throws IOException { if (isEmpty()) return null; boolean kt = generateKotlin == Boolean.TRUE || isKt(); diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java deleted file mode 100644 index a94ff25003..0000000000 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java +++ /dev/null @@ -1,1937 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.apt; - -import static io.jooby.internal.apt.AnnotationSupport.*; -import static io.jooby.internal.apt.CodeBlock.*; -import static java.lang.System.lineSeparator; -import static java.util.Optional.ofNullable; - -import java.util.*; -import java.util.function.Consumer; -import java.util.function.Function; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import javax.lang.model.element.*; -import javax.lang.model.type.TypeKind; -import javax.lang.model.type.TypeMirror; -import javax.lang.model.type.WildcardType; - -public class MvcRoute { - private final MvcContext context; - private final MvcRouter router; - private final ExecutableElement method; - private final Map annotationMap = new LinkedHashMap<>(); - private final List parameters; - private final TypeDefinition returnType; - private String generatedName; - private final boolean suspendFun; - private boolean uncheckedCast; - private final boolean hasBeanValidation; - private final Set pending = new HashSet<>(); - - private boolean isTrpc = false; - private boolean isJsonRpc = false; - private boolean isMcpTool = false; - private boolean isMcpPrompt = false; - private boolean isMcpResource = false; - private boolean isMcpResourceTemplate = false; - private boolean isMcpCompletion = false; - private HttpMethod resolvedTrpcMethod = null; - - public MvcRoute(MvcContext context, MvcRouter router, ExecutableElement method) { - this.context = context; - this.router = router; - this.method = method; - this.parameters = - method.getParameters().stream().map(it -> new MvcParameter(context, null, it)).toList(); - this.hasBeanValidation = parameters.stream().anyMatch(MvcParameter::isRequireBeanValidation); - this.suspendFun = - !parameters.isEmpty() - && parameters.get(parameters.size() - 1).getType().is("kotlin.coroutines.Continuation"); - this.returnType = - new TypeDefinition( - context.getProcessingEnvironment().getTypeUtils(), method.getReturnType()); - this.checkMcpAnnotations(); - } - - public MvcRoute(MvcContext context, MvcRouter router, MvcRoute route) { - this.context = context; - this.router = router; - this.method = route.method; - this.parameters = - method.getParameters().stream().map(it -> new MvcParameter(context, null, it)).toList(); - this.hasBeanValidation = parameters.stream().anyMatch(MvcParameter::isRequireBeanValidation); - this.returnType = - new TypeDefinition( - context.getProcessingEnvironment().getTypeUtils(), method.getReturnType()); - this.suspendFun = route.suspendFun; - // from here - this.isJsonRpc = route.isJsonRpc; - this.isTrpc = route.isTrpc; - this.isMcpTool = route.isMcpTool; - this.isMcpPrompt = route.isMcpPrompt; - this.isMcpResource = route.isMcpResource; - this.isMcpResourceTemplate = route.isMcpResourceTemplate; - this.isMcpCompletion = route.isMcpCompletion; - route.annotationMap.keySet().forEach(this::addHttpMethod); - } - - public MvcContext getContext() { - return context; - } - - public String getProjection() { - var project = AnnotationSupport.findAnnotationByName(method, Types.PROJECT); - if (project != null) { - return AnnotationSupport.findAnnotationValue(project, VALUE).stream() - .findFirst() - .orElse(null); - } - var httpMethod = annotationMap.values().iterator().next(); - var projection = AnnotationSupport.findAnnotationValue(httpMethod, "projection"::equals); - return projection.stream().findFirst().orElse(null); - } - - public boolean isTrpc() { - return isTrpc; - } - - // Inside the constructor or addHttpMethod equivalent, scan for the annotations: - public MvcRoute checkMcpAnnotations() { - if (AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.McpTool") - != null) { - this.isMcpTool = true; - } - if (AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.McpPrompt") - != null) { - this.isMcpPrompt = true; - } - - var resourceAnno = - AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.McpResource"); - if (resourceAnno != null) { - String uri = - AnnotationSupport.findAnnotationValue(resourceAnno, "value"::equals).stream() - .findFirst() - .orElse(""); - if (uri.contains("{") && uri.contains("}")) { - this.isMcpResourceTemplate = true; - } else { - this.isMcpResource = true; - } - } - - if (AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.McpCompletion") - != null) { - this.isMcpCompletion = true; - } - return this; - } - - // Add getters - public boolean isMcpTool() { - return isMcpTool; - } - - public boolean isMcpPrompt() { - return isMcpPrompt; - } - - public boolean isMcpResource() { - return isMcpResource; - } - - public boolean isMcpResourceTemplate() { - return isMcpResourceTemplate; - } - - public boolean isMcpCompletion() { - return isMcpCompletion; - } - - public boolean isMcpRoute() { - return isMcpTool || isMcpPrompt || isMcpResource || isMcpResourceTemplate || isMcpCompletion; - } - - public boolean isProjection() { - if (returnType.is(Types.PROJECTED)) { - return false; - } - var isProjection = AnnotationSupport.findAnnotationByName(method, Types.PROJECT) != null; - if (isProjection) { - return true; - } - var httpMethod = annotationMap.values().iterator().next(); - var projection = AnnotationSupport.findAnnotationValue(httpMethod, "projection"::equals); - return !projection.isEmpty(); - } - - public TypeDefinition getReturnType() { - var processingEnv = context.getProcessingEnvironment(); - var types = processingEnv.getTypeUtils(); - var elements = processingEnv.getElementUtils(); - if (isProjection()) { - return new TypeDefinition(types, elements.getTypeElement(Types.PROJECTED).asType(), true); - } else if (returnType.isVoid()) { - return new TypeDefinition(types, elements.getTypeElement("io.jooby.StatusCode").asType()); - } else if (isSuspendFun()) { - var continuation = parameters.get(parameters.size() - 1).getType(); - if (!continuation.getArguments().isEmpty()) { - var continuationReturnType = continuation.getArguments().get(0).getType(); - if (continuationReturnType instanceof WildcardType wildcardType) { - return Stream.of(wildcardType.getSuperBound(), wildcardType.getExtendsBound()) - .filter(Objects::nonNull) - .findFirst() - .map(e -> new TypeDefinition(types, e)) - .orElseGet(() -> new TypeDefinition(types, continuationReturnType)); - } else { - return new TypeDefinition(types, continuationReturnType); - } - } - } - return returnType; - } - - public TypeMirror getReturnTypeHandler() { - return getReturnType().getRawType(); - } - - public List generateMapping(boolean kt) { - if (isJsonRpc) { - return Collections.emptyList(); - } - - List block = new ArrayList<>(); - var methodName = getGeneratedName(); - var returnType = getReturnType(); - var paramString = String.join(", ", getJavaMethodSignature(kt)); - var javadocLink = javadocComment(kt); - var attributeGenerator = new RouteAttributesGenerator(context, hasBeanValidation); - var routes = router.getRoutes(); - var lastRoute = routes.get(routes.size() - 1).equals(this); - var entries = annotationMap.entrySet().stream().toList(); - var thisRef = - isSuspendFun() - ? "this@" - + context.generateRouterName(router.getTargetType().getSimpleName().toString()) - + "::" - : "this::"; - - for (var e : entries) { - var lastHttpMethod = lastRoute && entries.get(entries.size() - 1).equals(e); - var annotation = e.getKey(); - var httpMethod = HttpMethod.findByAnnotationName(annotation.getQualifiedName().toString()); - var dslMethod = annotation.getSimpleName().toString().toLowerCase(); - var paths = context.path(router.getTargetType(), method, annotation); - var targetMethod = methodName; - - if (httpMethod == HttpMethod.tRPC) { - resolvedTrpcMethod = trpcMethod(method); - if (resolvedTrpcMethod == null) { - throw new IllegalArgumentException( - "tRPC method not found: " - + method.getSimpleName() - + "() in " - + router.getTargetType()); - } - dslMethod = resolvedTrpcMethod.name().toLowerCase(); - paths = List.of(trpcPath(method)); - targetMethod = - "trpc" + targetMethod.substring(0, 1).toUpperCase() + targetMethod.substring(1); - this.isTrpc = true; - } - - pending.add(targetMethod); - - for (var path : paths) { - var lastLine = lastHttpMethod && paths.getLast().equals(path); - block.add(javadocLink); - block.add( - statement( - isSuspendFun() ? "" : "app.", - dslMethod, - "(", - string(leadingSlash(path)), - ", ", - context.pipeline( - getReturnTypeHandler(), methodReference(kt, thisRef, targetMethod)))); - if (context.nonBlocking(getReturnTypeHandler()) || isSuspendFun()) { - block.add(statement(indent(2), ".setNonBlocking(true)")); - } - mediaType(httpMethod::consumes) - .ifPresent(consumes -> block.add(statement(indent(2), ".setConsumes(", consumes, ")"))); - mediaType(httpMethod::produces) - .ifPresent(produces -> block.add(statement(indent(2), ".setProduces(", produces, ")"))); - dispatch() - .ifPresent( - dispatch -> - block.add(statement(indent(2), ".setExecutorKey(", string(dispatch), ")"))); - attributeGenerator - .toSourceCode(kt, null, 2) - .ifPresent( - attributes -> block.add(statement(indent(2), ".setAttributes(", attributes, ")"))); - var lineSep = lastLine ? lineSeparator() : lineSeparator() + lineSeparator(); - if (context.generateMvcMethod()) { - block.add( - CodeBlock.of( - indent(2), - ".setMvcMethod(", - kt ? "" : "new ", - "io.jooby.Route.MvcMethod(", - router.getTargetType().getSimpleName().toString(), - clazz(kt), - ", ", - string(getMethodName()), - ", ", - type(kt, returnType.getRawType().toString()), - clazz(kt), - paramString.isEmpty() ? "" : ", " + paramString, - "))", - semicolon(kt), - lineSep)); - } else { - var lastStatement = block.get(block.size() - 1); - if (lastStatement.endsWith(lineSeparator())) { - lastStatement = - lastStatement.substring(0, lastStatement.length() - lineSeparator().length()); - } - block.set(block.size() - 1, lastStatement + semicolon(kt) + lineSep); - } - } - } - return block; - } - - private String methodReference(boolean kt, String thisRef, String methodName) { - if (kt) { - var returnType = getReturnType(); - var generics = returnType.getArgumentsString(kt, true, Set.of(TypeKind.TYPEVAR)); - if (!generics.isEmpty()) { - return CodeBlock.of(") { ctx -> ", methodName, generics, "(ctx) }"); - } - } - return thisRef + methodName + ")"; - } - - static String leadingSlash(String path) { - if (path == null || path.isEmpty() || path.equals("/")) { - return "/"; - } - return path.charAt(0) == '/' ? path : "/" + path; - } - - public List generateMcpDefinitionMethod(boolean kt) { - List buffer = new ArrayList<>(); - - if (isMcpTool()) { - String toolName = extractAnnotationValue(this, "io.jooby.annotation.McpTool", "name"); - if (toolName.isEmpty()) toolName = getMethodName(); - String description = - extractAnnotationValue(this, "io.jooby.annotation.McpTool", "description"); - - if (kt) { - buffer.add( - statement( - indent(4), - "private fun ", - getMethodName(), - "ToolSpec(mapper: tools.jackson.databind.ObjectMapper, schemaGenerator:" - + " com.github.victools.jsonschema.generator.SchemaGenerator):" - + " io.modelcontextprotocol.spec.McpSchema.Tool {")); - buffer.add(statement(indent(6), "val schema = mapper.createObjectNode()")); - buffer.add( - statement(indent(6), "schema.put(", string("type"), ", ", string("object"), ")")); - buffer.add( - statement(indent(6), "val props = schema.putObject(", string("properties"), ")")); - buffer.add(statement(indent(6), "val req = schema.putArray(", string("required"), ")")); - } else { - buffer.add( - statement( - indent(4), - "private io.modelcontextprotocol.spec.McpSchema.Tool ", - getMethodName(), - "ToolSpec(tools.jackson.databind.ObjectMapper mapper," - + " com.github.victools.jsonschema.generator.SchemaGenerator" - + " schemaGenerator) {")); - buffer.add(statement(indent(6), "var schema = mapper.createObjectNode()", semicolon(kt))); - buffer.add( - statement( - indent(6), - "schema.put(", - string("type"), - ", ", - string("object"), - ")", - semicolon(kt))); - buffer.add( - statement( - indent(6), - "var props = schema.putObject(", - string("properties"), - ")", - semicolon(kt))); - buffer.add( - statement( - indent(6), "var req = schema.putArray(", string("required"), ")", semicolon(kt))); - } - - for (MvcParameter param : getParameters(false)) { - String type = param.getType().getRawType().toString(); - if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange") - || type.equals("io.jooby.Context")) continue; - - String mcpName = param.getMcpName(); - - if (kt) { - buffer.add( - statement( - indent(6), - "props.set(", - string(mcpName), - ", schemaGenerator.generateSchema(", - type, - "::class.java))")); - if (!param.isNullable(kt)) - buffer.add(statement(indent(6), "req.add(", string(mcpName), ")")); - } else { - buffer.add( - statement( - indent(6), - "props.set(", - string(mcpName), - ", schemaGenerator.generateSchema(", - type, - ".class))", - semicolon(kt))); - if (!param.isNullable(kt)) - buffer.add(statement(indent(6), "req.add(", string(mcpName), ")", semicolon(kt))); - } - } - - // Filter out primitives, java.lang classes (String, Integer, etc.), and MCP classes - String returnTypeStr = getReturnType().getRawType().toString(); - boolean isPrimitive = - returnTypeStr.equals("int") - || returnTypeStr.equals("long") - || returnTypeStr.equals("double") - || returnTypeStr.equals("float") - || returnTypeStr.equals("boolean") - || returnTypeStr.equals("byte") - || returnTypeStr.equals("short") - || returnTypeStr.equals("char"); - boolean isLangClass = returnTypeStr.startsWith("java.lang."); - boolean isMcpClass = returnTypeStr.startsWith("io.modelcontextprotocol.spec.McpSchema"); - - boolean generateOutputSchema = - !returnType.isVoid() - && !getReturnType().is("io.jooby.StatusCode") - && !isPrimitive - && !isLangClass - && !isMcpClass; - - String outputSchemaArg = "null"; - - if (generateOutputSchema) { - outputSchemaArg = getMethodName() + "OutputSchema"; - if (kt) { - buffer.add( - statement( - indent(6), - "val ", - outputSchemaArg, - "Node = schemaGenerator.generateSchema(", - returnTypeStr, - "::class.java)")); - // Clean and simple Map class conversion! - buffer.add( - statement( - indent(6), - "val ", - outputSchemaArg, - " = mapper.convertValue(", - outputSchemaArg, - "Node, Map::class.java) as Map")); - } else { - buffer.add( - statement( - indent(6), - "var ", - outputSchemaArg, - "Node = schemaGenerator.generateSchema(", - returnTypeStr, - ".class)", - semicolon(kt))); - // Clean and simple Map class conversion! - buffer.add( - statement( - indent(6), - "var ", - outputSchemaArg, - " = mapper.convertValue(", - outputSchemaArg, - "Node, java.util.Map.class)", - semicolon(kt))); - } - } - - if (kt) { - buffer.add( - statement( - indent(6), - "return io.modelcontextprotocol.spec.McpSchema.Tool(", - string(toolName), - ", null, ", - string(description), - ", mapper.treeToValue(schema," - + " io.modelcontextprotocol.spec.McpSchema.JsonSchema::class.java), ", - outputSchemaArg, - ", null, null)")); - } else { - buffer.add( - statement( - indent(6), - "return new io.modelcontextprotocol.spec.McpSchema.Tool(", - string(toolName), - ", null, ", - string(description), - ", mapper.treeToValue(schema," - + " io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), ", - outputSchemaArg, - ", null, null)", - semicolon(kt))); - } - buffer.add(statement(indent(4), "}\n")); - - } else if (isMcpPrompt()) { - String promptName = extractAnnotationValue(this, "io.jooby.annotation.McpPrompt", "name"); - if (promptName.isEmpty()) promptName = getMethodName(); - String description = - extractAnnotationValue(this, "io.jooby.annotation.McpPrompt", "description"); - - if (kt) { - buffer.add( - statement( - indent(4), - "private fun ", - getMethodName(), - "PromptSpec(): io.modelcontextprotocol.spec.McpSchema.Prompt {")); - buffer.add( - statement( - indent(6), - "val args =" - + " mutableListOf()")); - } else { - buffer.add( - statement( - indent(4), - "private io.modelcontextprotocol.spec.McpSchema.Prompt ", - getMethodName(), - "PromptSpec() {")); - buffer.add( - statement( - indent(6), - "var args = new" - + " java.util.ArrayList()", - semicolon(kt))); - } - - for (MvcParameter param : getParameters(false)) { - String type = param.getType().getRawType().toString(); - if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange") - || type.equals("io.jooby.Context")) continue; - - String mcpName = param.getMcpName(); - boolean isRequired = !param.isNullable(kt); - - if (kt) { - buffer.add( - statement( - indent(6), - "args.add(io.modelcontextprotocol.spec.McpSchema.PromptArgument(", - string(mcpName), - ", null, ", - String.valueOf(isRequired), - "))")); - } else { - buffer.add( - statement( - indent(6), - "args.add(new io.modelcontextprotocol.spec.McpSchema.PromptArgument(", - string(mcpName), - ", null, ", - String.valueOf(isRequired), - "))", - semicolon(kt))); - } - } - - if (kt) { - buffer.add( - statement( - indent(6), - "return io.modelcontextprotocol.spec.McpSchema.Prompt(", - string(promptName), - ", null, ", - string(description), - ", args)")); - } else { - buffer.add( - statement( - indent(6), - "return new io.modelcontextprotocol.spec.McpSchema.Prompt(", - string(promptName), - ", null, ", - string(description), - ", args)", - semicolon(kt))); - } - buffer.add(statement(indent(4), "}\n")); - - } else if (isMcpResource() || isMcpResourceTemplate()) { - String uri = extractAnnotationValue(this, "io.jooby.annotation.McpResource", "value"); - String name = extractAnnotationValue(this, "io.jooby.annotation.McpResource", "name"); - if (name.isEmpty()) name = getMethodName(); - String description = - extractAnnotationValue(this, "io.jooby.annotation.McpResource", "description"); - - boolean isTemplate = uri != null && uri.contains("{") && uri.contains("}"); - String specType = isTemplate ? "ResourceTemplate" : "Resource"; - - if (kt) { - buffer.add( - statement( - indent(4), - "private fun ", - getMethodName(), - specType, - "Spec(): io.modelcontextprotocol.spec.McpSchema.", - specType, - " {")); - if (!isTemplate) { - buffer.add( - statement( - indent(6), - "return io.modelcontextprotocol.spec.McpSchema.Resource(", - string(uri), - ", ", - string(name), - ", null, ", - string(description), - ", null, null, null, null)")); - } else { - buffer.add( - statement( - indent(6), - "return io.modelcontextprotocol.spec.McpSchema.ResourceTemplate(", - string(uri), - ", ", - string(name), - ", null, ", - string(description), - ", null, null, null)")); - } - buffer.add(statement(indent(4), "}\n")); - } else { - buffer.add( - statement( - indent(4), - "private io.modelcontextprotocol.spec.McpSchema.", - specType, - " ", - getMethodName(), - specType, - "Spec() {")); - if (!isTemplate) { - buffer.add( - statement( - indent(6), - "return new io.modelcontextprotocol.spec.McpSchema.Resource(", - string(uri), - ", ", - string(name), - ", null, ", - string(description), - ", null, null, null, null)", - semicolon(kt))); - } else { - buffer.add( - statement( - indent(6), - "return new io.modelcontextprotocol.spec.McpSchema.ResourceTemplate(", - string(uri), - ", ", - string(name), - ", null, ", - string(description), - ", null, null, null)", - semicolon(kt))); - } - buffer.add(statement(indent(4), "}\n")); - } - } - return buffer; - } - - public List generateMcpHandlerMethod(boolean kt) { - List buffer = new ArrayList<>(); - - String reqType = ""; - String resType = ""; - String toMethod = ""; - - if (isMcpTool()) { - reqType = "CallToolRequest"; - resType = "CallToolResult"; - toMethod = "toCallToolResult"; - } else if (isMcpPrompt()) { - reqType = "GetPromptRequest"; - resType = "GetPromptResult"; - toMethod = "toPromptResult"; - } else if (isMcpResource() || isMcpResourceTemplate()) { - reqType = "ReadResourceRequest"; - resType = "ReadResourceResult"; - toMethod = "toResourceResult"; - } else { - return buffer; - } - - if (kt) { - buffer.add( - statement( - indent(4), - "private fun ", - getMethodName(), - "(exchange: io.modelcontextprotocol.server.McpSyncServerExchange, req:" - + " io.modelcontextprotocol.spec.McpSchema.", - reqType, - "): io.modelcontextprotocol.spec.McpSchema.", - resType, - " {")); - buffer.add( - statement( - indent(6), - "val ctx =" - + " exchange.transportContext().get(io.jooby.Context::class.java.name)")); - } else { - buffer.add( - statement( - indent(4), - "private io.modelcontextprotocol.spec.McpSchema.", - resType, - " ", - getMethodName(), - "(io.modelcontextprotocol.server.McpSyncServerExchange exchange," - + " io.modelcontextprotocol.spec.McpSchema.", - reqType, - " req) {")); - buffer.add( - statement( - indent(6), - "var ctx = (io.jooby.Context) exchange.transportContext().get(\"CTX\")", - semicolon(kt))); - } - - if (isMcpTool() || isMcpPrompt()) { - if (kt) { - buffer.add(statement(indent(6), "val args = req.arguments() ?: emptyMap()")); - } else { - buffer.add( - statement( - indent(6), - "var args = req.arguments() != null ? req.arguments() :" - + " java.util.Collections.emptyMap()", - semicolon(kt))); - } - } else if (isMcpResource() || isMcpResourceTemplate()) { - String uriTemplate = extractAnnotationValue(this, "io.jooby.annotation.McpResource", "value"); - boolean isTemplate = isMcpResourceTemplate(); - - if (isTemplate) { - if (kt) { - buffer.add(statement(indent(6), "val uri = req.uri()")); - buffer.add( - statement( - indent(6), - "val manager = io.modelcontextprotocol.util.DefaultMcpUriTemplateManager(", - string(uriTemplate), - ")")); - buffer.add(statement(indent(6), "val args = mutableMapOf()")); - buffer.add(statement(indent(6), "args.putAll(manager.extractVariableValues(uri))")); - } else { - buffer.add(statement(indent(6), "var uri = req.uri()", semicolon(kt))); - buffer.add( - statement( - indent(6), - "var manager = new io.modelcontextprotocol.util.DefaultMcpUriTemplateManager(", - string(uriTemplate), - ")", - semicolon(kt))); - buffer.add( - statement( - indent(6), "var args = new java.util.HashMap()", semicolon(kt))); - buffer.add( - statement( - indent(6), "args.putAll(manager.extractVariableValues(uri))", semicolon(kt))); - } - } else { - if (kt) { - buffer.add(statement(indent(6), "val args = emptyMap()")); - } else { - buffer.add( - statement( - indent(6), - "var args = java.util.Collections.emptyMap()", - semicolon(kt))); - } - } - } - - buffer.add( - statement(indent(6), kt ? "val " : "var ", "c = this.factory.apply(ctx)", semicolon(kt))); - - List javaParamNames = new ArrayList<>(); - for (MvcParameter param : getParameters(false)) { - String javaName = param.getName(); - String mcpName = param.getMcpName(); - String type = param.getType().getRawType().toString(); - boolean isNullable = param.isNullable(kt); - javaParamNames.add(javaName); - - if (type.equals("io.jooby.Context")) { - buffer.add(statement(indent(6), kt ? "val " : "var ", javaName, " = ctx", semicolon(kt))); - continue; - } else if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange")) { - buffer.add( - statement(indent(6), kt ? "val " : "var ", javaName, " = exchange", semicolon(kt))); - continue; - } else if (type.equals("io.modelcontextprotocol.spec.McpSchema." + reqType)) { - buffer.add(statement(indent(6), kt ? "val " : "var ", javaName, " = req", semicolon(kt))); - continue; - } - - if (kt) { - buffer.add( - statement(indent(6), "val raw_", javaName, " = args.get(", string(mcpName), ")")); - if (!isNullable) - buffer.add( - statement( - indent(6), - "if (raw_", - javaName, - " == null) throw IllegalArgumentException(", - string("Missing req param: " + mcpName), - ")")); - buffer.add( - statement( - indent(6), - "val ", - javaName, - " = raw_", - javaName, - " as ", - type, - isNullable ? "?" : "")); - } else { - buffer.add( - statement( - indent(6), - "var raw_", - javaName, - " = args.get(", - string(mcpName), - ")", - semicolon(kt))); - if (!isNullable) - buffer.add( - statement( - indent(6), - "if (raw_", - javaName, - " == null) throw new IllegalArgumentException(", - string("Missing req param: " + mcpName), - ")", - semicolon(kt))); - - if (type.equals("int") || type.equals("java.lang.Integer")) { - buffer.add( - statement( - indent(6), - "var ", - javaName, - " = ", - isNullable ? "(raw_" + javaName + " == null) ? null : " : "", - "raw_", - javaName, - " instanceof Number ? ((Number) raw_", - javaName, - ").intValue() : Integer.parseInt(raw_", - javaName, - ".toString())", - semicolon(kt))); - } else if (type.equals("java.lang.String")) { - buffer.add( - statement( - indent(6), - "var ", - javaName, - " = raw_", - javaName, - " != null ? raw_", - javaName, - ".toString() : null", - semicolon(kt))); - } else { - buffer.add( - statement( - indent(6), "var ", javaName, " = (", type, ") raw_", javaName, semicolon(kt))); - } - } - } - - String methodCall = "c." + getMethodName() + "(" + String.join(", ", javaParamNames) + ")"; - - if (getReturnType().isVoid()) { - buffer.add(statement(indent(6), methodCall, semicolon(kt))); - if (kt) { - buffer.add( - statement(indent(6), "return io.jooby.mcp.McpResult(this.json).", toMethod, "(null)")); - } else { - buffer.add( - statement( - indent(6), - "return new io.jooby.mcp.McpResult(this.json).", - toMethod, - "(null)", - semicolon(kt))); - } - } else { - if (kt) { - buffer.add(statement(indent(6), "val result = ", methodCall)); - buffer.add( - statement( - indent(6), "return io.jooby.mcp.McpResult(this.json).", toMethod, "(result)")); - } else { - buffer.add(statement(indent(6), "var result = ", methodCall, semicolon(kt))); - buffer.add( - statement( - indent(6), - "return new io.jooby.mcp.McpResult(this.json).", - toMethod, - "(result)", - semicolon(kt))); - } - } - buffer.add(statement(indent(4), "}\n")); - - return buffer; - } - - public List generateJsonRpcDispatchCase(boolean kt) { - var buffer = new ArrayList(); - var paramList = new StringJoiner(", ", "(", ")"); - - // Check if we have any parameters that actually need to be parsed from the JSON payload. - // We ignore Jooby's Context and Kotlin's Continuation since they are provided by the framework. - boolean needsReader = - parameters.stream() - .anyMatch( - p -> { - String type = p.getType().toString(); - return !type.equals("io.jooby.Context") - && !type.startsWith("kotlin.coroutines.Continuation"); - }); - - if (needsReader) { - if (kt) { - buffer.add(statement(indent(8), "parser.reader(req.params).use { reader ->")); - } else { - buffer.add(statement(indent(8), "try (var reader = parser.reader(req.getParams())) {")); - } - } - - // This method will now be responsible for pushing "ctx" directly to paramList - // for Context parameters, instead of reading them from the JSON. - buffer.addAll(generateRpcParameter(kt, paramList::add, true)); - - // Dynamically adjust indentation based on whether the reader block was opened - int callIndent = needsReader ? 10 : 8; - var call = CodeBlock.of("c.", getMethodName(), paramList.toString()); - - if (returnType.isVoid()) { - buffer.add(statement(indent(callIndent), call, semicolon(kt))); - buffer.add(statement(indent(callIndent), kt ? "null" : "return null", semicolon(kt))); - } else { - buffer.add(statement(indent(callIndent), kt ? call : "return " + call, semicolon(kt))); - } - - if (needsReader) { - buffer.add(statement(indent(8), "}")); - } - - return buffer; - } - - public List generateHandlerCall(boolean kt) { - if (isJsonRpc) { - return Collections.emptyList(); - } - - var buffer = new ArrayList(); - var methodName = - isTrpc - ? "trpc" - + getGeneratedName().substring(0, 1).toUpperCase() - + getGeneratedName().substring(1) - : getGeneratedName(); - - if (pending.contains(methodName)) { - var paramList = new StringJoiner(", ", "(", ")"); - var returnTypeGenerics = - getReturnType().getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); - var returnTypeString = type(kt, getReturnType().toString()); - var customReturnType = getReturnType(); - - if (customReturnType.isProjection()) { - returnTypeGenerics = ""; - returnTypeString = Types.PROJECTED + "<" + returnType + ">"; - } - - var reactive = isTrpc ? context.getReactiveType(returnType.getRawType()) : null; - var isReactiveVoid = false; - var innerReactiveType = "Object"; - - var methodReturnTypeString = returnTypeString; - if (isTrpc) { - if (reactive != null) { - var rawReactiveType = type(kt, returnType.getRawType().toString()); - if (!returnType.getArguments().isEmpty()) { - innerReactiveType = type(kt, returnType.getArguments().get(0).getRawType().toString()); - if (innerReactiveType.equals("java.lang.Void") || innerReactiveType.equals("Void")) { - isReactiveVoid = true; - innerReactiveType = kt ? "Unit" : "Void"; - } - } else if (rawReactiveType.contains("Completable")) { - isReactiveVoid = true; - innerReactiveType = kt ? "Unit" : "Void"; - } - methodReturnTypeString = - rawReactiveType + ">"; - } else { - methodReturnTypeString = - "io.jooby.rpc.trpc.TrpcResponse<" - + (returnType.isVoid() ? (kt ? "Unit" : "Void") : returnTypeString) - + ">"; - } - } - - var nullable = - methodCallHeader( - kt, - "ctx", - methodName, - buffer, - returnTypeGenerics, - methodReturnTypeString, - isTrpc || !method.getThrownTypes().isEmpty()); - - int controllerIndent = 2; - - if (isTrpc && !parameters.isEmpty()) { - controllerIndent = 4; - buffer.add( - statement( - indent(2), - var(kt), - "parser = ctx.require(io.jooby.rpc.trpc.TrpcParser", - clazz(kt), - ")", - semicolon(kt))); - - long trpcPayloadCount = - parameters.stream() - .filter( - p -> { - String type = p.getType().getRawType().toString(); - return !type.equals("io.jooby.Context") - && !p.getType().is("kotlin.coroutines.Continuation"); - }) - .count(); - boolean isTuple = trpcPayloadCount > 1; - - if (resolvedTrpcMethod == HttpMethod.GET) { - buffer.add( - statement( - indent(2), - var(kt), - "input = ctx.query(", - string("input"), - ").value()", - semicolon(kt))); - - if (isTuple) { - if (kt) { - buffer.add( - statement( - indent(2), - "if (input?.trim()?.let { it.startsWith('[') && it.endsWith(']') } != true)" - + " throw IllegalArgumentException(", - string("tRPC input for multiple arguments must be a JSON array (tuple)"), - ")")); - } else { - buffer.add( - statement( - indent(2), - "if (input == null || input.length() < 2 || input.charAt(0) != '[' ||" - + " input.charAt(input.length() - 1) != ']') throw new" - + " IllegalArgumentException(", - string("tRPC input for multiple arguments must be a JSON array (tuple)"), - ");")); - } - } - } else { - buffer.add(statement(indent(2), var(kt), "input = ctx.body().bytes()", semicolon(kt))); - - if (isTuple) { - if (kt) { - buffer.add( - statement( - indent(2), - "if (input.size < 2 || input[0] != '['.code.toByte() || input[input.size - 1]" - + " != ']'.code.toByte()) throw IllegalArgumentException(", - string("tRPC body for multiple arguments must be a JSON array (tuple)"), - ")")); - } else { - buffer.add( - statement( - indent(2), - "if (input.length < 2 || input[0] != '[' || input[input.length - 1] != ']')" - + " throw new IllegalArgumentException(", - string("tRPC body for multiple arguments must be a JSON array (tuple)"), - ");")); - } - } - } - - if (kt) { - buffer.add( - statement( - indent(2), - "parser.reader(input, ", - String.valueOf(isTuple), - ").use { reader -> ")); - } else { - buffer.add( - statement( - indent(2), - "try (var reader = parser.reader(input, ", - String.valueOf(isTuple), - ")) {")); - } - - buffer.addAll(generateRpcParameter(kt, paramList::add, false)); - } else if (!isTrpc) { - for (var parameter : getParameters(true)) { - String generatedParameter = parameter.generateMapping(kt); - if (parameter.isRequireBeanValidation()) { - generatedParameter = - CodeBlock.of( - "io.jooby.validation.BeanValidator.apply(", "ctx, ", generatedParameter, ")"); - } - paramList.add(generatedParameter); - } - } - - controllerVar(kt, buffer, controllerIndent); - - if (returnType.isVoid()) { - String statusCode = - annotationMap.size() == 1 - && annotationMap - .keySet() - .iterator() - .next() - .getSimpleName() - .toString() - .equals("DELETE") - ? "NO_CONTENT" - : "OK"; - - if (annotationMap.size() == 1) { - buffer.add( - statement( - indent(controllerIndent), - "ctx.setResponseCode(io.jooby.StatusCode.", - statusCode, - ")", - semicolon(kt))); - } else { - if (kt) { - buffer.add( - statement( - indent(controllerIndent), - "ctx.setResponseCode(if (ctx.getRoute().getMethod().equals(", - string("DELETE"), - ")) io.jooby.StatusCode.NO_CONTENT else io.jooby.StatusCode.OK)")); - } else { - buffer.add( - statement( - indent(controllerIndent), - "ctx.setResponseCode(ctx.getRoute().getMethod().equals(", - string("DELETE"), - ") ? io.jooby.StatusCode.NO_CONTENT: io.jooby.StatusCode.OK)", - semicolon(false))); - } - } - - buffer.add( - statement( - indent(controllerIndent), - "c.", - this.method.getSimpleName(), - paramList.toString(), - semicolon(kt))); - - if (isTrpc) { - buffer.add( - statement( - indent(controllerIndent), - "return io.jooby.rpc.trpc.TrpcResponse.empty()", - semicolon(kt))); - } else { - buffer.add( - statement(indent(controllerIndent), "return ctx.getResponseCode()", semicolon(kt))); - } - } else if (returnType.is("io.jooby.StatusCode")) { - buffer.add( - statement( - indent(controllerIndent), - kt ? "val" : "var", - " statusCode = c.", - this.method.getSimpleName(), - paramList.toString(), - semicolon(kt))); - buffer.add( - statement(indent(controllerIndent), "ctx.setResponseCode(statusCode)", semicolon(kt))); - - if (isTrpc) { - buffer.add( - statement( - indent(controllerIndent), - "return io.jooby.rpc.trpc.TrpcResponse.of(statusCode)", - semicolon(kt))); - } else { - buffer.add(statement(indent(controllerIndent), "return statusCode", semicolon(kt))); - } - } else { - var castStr = - customReturnType.isProjection() - ? "" - : customReturnType.getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); - - var needsCast = - !castStr.isEmpty() - || (kt - && !customReturnType.isProjection() - && !customReturnType.getArguments().isEmpty()); - - var kotlinNotEnoughTypeInformation = !castStr.isEmpty() && kt ? "" : ""; - var call = - of( - "c.", - this.method.getSimpleName(), - kotlinNotEnoughTypeInformation, - paramList.toString()); - - if (needsCast) { - setUncheckedCast(true); - call = kt ? call + " as " + returnTypeString : "(" + returnTypeString + ") " + call; - } - - if (customReturnType.isProjection()) { - var projected = - of(Types.PROJECTED, ".wrap(", call, ").include(", string(getProjection()), ")"); - if (isTrpc) { - buffer.add( - statement( - indent(controllerIndent), - "return io.jooby.rpc.trpc.TrpcResponse.of(", - projected, - ")", - semicolon(kt))); - } else { - buffer.add( - statement( - indent(controllerIndent), - "return ", - projected, - kt && nullable ? "!!" : "", - semicolon(kt))); - } - } else { - - if (isTrpc && reactive != null) { - if (isReactiveVoid) { - var handler = reactive.handlerType(); - if (handler.contains("Reactor")) { - buffer.add( - statement( - indent(controllerIndent), - "return ", - call, - ".then(reactor.core.publisher.Mono.just(io.jooby.rpc.trpc.TrpcResponse.empty()))", - semicolon(kt))); - } else if (handler.contains("Mutiny")) { - buffer.add( - statement( - indent(controllerIndent), - "return ", - call, - ".replaceWith(io.jooby.rpc.trpc.TrpcResponse.empty())", - semicolon(kt))); - } else if (handler.contains("ReactiveSupport")) { - buffer.add( - statement( - indent(controllerIndent), - "return ", - call, - ".thenApply(x -> io.jooby.rpc.trpc.TrpcResponse.empty())", - semicolon(kt))); - } else if (handler.contains("Reactivex")) { - buffer.add( - statement( - indent(controllerIndent), - "return ", - call, - ".toSingleDefault(io.jooby.rpc.trpc.TrpcResponse.empty())", - semicolon(kt))); - } else { - buffer.add( - statement( - indent(controllerIndent), - "return ", - call, - ".map(x -> io.jooby.rpc.trpc.TrpcResponse.empty())", - semicolon(kt))); - } - } else { - buffer.add( - statement( - indent(controllerIndent), - "return ", - call, - reactive.mapOperator(), - semicolon(kt))); - } - } else if (isTrpc) { - buffer.add( - statement( - indent(controllerIndent), - "return io.jooby.rpc.trpc.TrpcResponse.of(", - call, - kt && nullable ? "!!" : "", - ")", - semicolon(kt))); - } else { - buffer.add( - statement( - indent(controllerIndent), - "return ", - call, - kt && nullable ? "!!" : "", - semicolon(kt))); - } - } - } - - if (isTrpc && !parameters.isEmpty()) { - buffer.add(statement(indent(2), "}")); - } - - buffer.add(statement("}", lineSeparator())); - - if (uncheckedCast) { - if (kt) { - buffer.addFirst(statement("@Suppress(\"UNCHECKED_CAST\")")); - } else { - buffer.addFirst(statement("@SuppressWarnings(\"unchecked\")")); - } - } - } - return buffer; - } - - private boolean methodCallHeader( - boolean kt, - String contextVarname, - String methodName, - ArrayList buffer, - String returnTypeGenerics, - String returnTypeString, - boolean throwsException) { - var nullable = false; - if (kt) { - nullable = - method.getAnnotationMirrors().stream() - .map(AnnotationMirror::getAnnotationType) - .map(Objects::toString) - .anyMatch(NULLABLE); - if (throwsException) { - buffer.add(statement("@Throws(Exception::class)")); - } - if (isSuspendFun()) { - buffer.add( - statement( - "suspend ", - "fun ", - returnTypeGenerics, - methodName, - "(handler: io.jooby.kt.HandlerContext): ", - returnTypeString, - " {")); - buffer.add(statement(indent(2), "val ", contextVarname, " = handler.ctx")); - } else { - buffer.add( - statement( - "fun ", - returnTypeGenerics, - methodName, - "(", - contextVarname, - ": io.jooby.Context): ", - returnTypeString, - " {")); - } - } else { - buffer.add( - statement( - "public ", - returnTypeGenerics, - returnTypeString, - " ", - methodName, - "(io.jooby.Context ", - contextVarname, - ") ", - throwsException ? "throws Exception {" : "{")); - } - return nullable; - } - - private List generateRpcParameter( - boolean kt, Consumer arguments, boolean isJsonRpc) { - var statements = new ArrayList(); - var decoderInterface = - isJsonRpc ? "io.jooby.rpc.jsonrpc.JsonRpcDecoder" : "io.jooby.rpc.trpc.TrpcDecoder"; - int baseIndent = isJsonRpc ? 10 : 4; - - for (var parameter : parameters) { - var paramenterName = parameter.getName(); - var type = type(kt, parameter.getType().toString()); - boolean isNullable = parameter.isNullable(kt); - - switch (parameter.getType().getRawType().toString()) { - case "io.jooby.Context": - arguments.accept("ctx"); - break; - case "int", - "long", - "double", - "boolean", - "java.lang.String", - "java.lang.Integer", - "java.lang.Long", - "java.lang.Double", - "java.lang.Boolean": - var simpleType = type.startsWith("java.lang.") ? type.substring(10) : type; - if (simpleType.equals("Integer") || simpleType.equals("int")) simpleType = "Int"; - var readName = - "next" + Character.toUpperCase(simpleType.charAt(0)) + simpleType.substring(1); - - if (isNullable) { - if (kt) { - statements.add( - statement( - indent(baseIndent), - "val ", - paramenterName, - " = if (reader.nextIsNull(", - string(paramenterName), - ")) null else reader.", - readName, - "(", - string(paramenterName), - ")")); - } else { - statements.add( - statement( - indent(baseIndent), - var(kt), - paramenterName, - " = reader.nextIsNull(", - string(paramenterName), - ") ? null : reader.", - readName, - "(", - string(paramenterName), - ")", - semicolon(kt))); - } - } else { - statements.add( - statement( - indent(baseIndent), - var(kt), - paramenterName, - " = reader.", - readName, - "(", - string(paramenterName), - ")", - semicolon(kt))); - } - arguments.accept(paramenterName); - break; - case "byte", - "short", - "float", - "char", - "java.lang.Byte", - "java.lang.Short", - "java.lang.Float", - "java.lang.Character": - var isChar = type.equals("char") || type.equals("java.lang.Character"); - var isFloat = type.equals("float") || type.equals("java.lang.Float"); - var readMethod = isFloat ? "nextDouble" : (isChar ? "nextString" : "nextInt"); - - if (isNullable) { - if (kt) { - var ktCast = - isChar - ? "?.get(0)" - : "?.to" - + Character.toUpperCase(type.replace("java.lang.", "").charAt(0)) - + type.replace("java.lang.", "").substring(1) - + "()"; - statements.add( - statement( - indent(baseIndent), - "val ", - paramenterName, - " = if (reader.nextIsNull(", - string(paramenterName), - ")) null else reader.", - readMethod, - "(", - string(paramenterName), - ")", - ktCast)); - } else { - var targetType = type.replace("java.lang.", ""); - var javaPrefix = isChar ? "" : "(" + targetType + ") "; - var javaSuffix = isChar ? ".charAt(0)" : ""; - statements.add( - statement( - indent(baseIndent), - var(kt), - paramenterName, - " = reader.nextIsNull(", - string(paramenterName), - ") ? null : ", - javaPrefix, - "reader.", - readMethod, - "(", - string(paramenterName), - ")", - javaSuffix, - semicolon(kt))); - } - } else { - if (kt) { - var ktCast = - isChar - ? "[0]" - : ".to" - + Character.toUpperCase(type.replace("java.lang.", "").charAt(0)) - + type.replace("java.lang.", "").substring(1) - + "()"; - statements.add( - statement( - indent(baseIndent), - var(kt), - paramenterName, - " = reader.", - readMethod, - "(", - string(paramenterName), - ")", - ktCast, - semicolon(kt))); - } else { - var targetType = type.replace("java.lang.", ""); - var javaPrefix = isChar ? "" : "(" + targetType + ") "; - var javaSuffix = isChar ? ".charAt(0)" : ""; - statements.add( - statement( - indent(baseIndent), - var(kt), - paramenterName, - " = ", - javaPrefix, - "reader.", - readMethod, - "(", - string(paramenterName), - ")", - javaSuffix, - semicolon(kt))); - } - } - arguments.accept(paramenterName); - break; - default: - if (kt) { - statements.add( - statement( - indent(baseIndent), - "val ", - paramenterName, - "Decoder: ", - decoderInterface, - "<", - type, - "> = parser.decoder(", - parameter.getType().toSourceCode(kt), - ")", - semicolon(kt))); - if (isNullable) { - statements.add( - statement( - indent(baseIndent), - "val ", - paramenterName, - " = if (reader.nextIsNull(", - string(paramenterName), - ")) null else reader.nextObject(", - string(paramenterName), - ", ", - paramenterName, - "Decoder)")); - } else { - statements.add( - statement( - indent(baseIndent), - "val ", - paramenterName, - " = reader.nextObject(", - string(paramenterName), - ", ", - paramenterName, - "Decoder)", - semicolon(kt))); - } - } else { - statements.add( - statement( - indent(baseIndent), - decoderInterface, - "<", - type, - "> ", - paramenterName, - "Decoder = parser.decoder(", - parameter.getType().toSourceCode(kt), - ")", - semicolon(kt))); - if (isNullable) { - statements.add( - statement( - indent(baseIndent), - parameter.getType().toString(), - " ", - paramenterName, - " = reader.nextIsNull(", - string(paramenterName), - ") ? null : reader.nextObject(", - string(paramenterName), - ", ", - paramenterName, - "Decoder)", - semicolon(kt))); - } else { - statements.add( - statement( - indent(baseIndent), - parameter.getType().toString(), - " ", - paramenterName, - " = reader.nextObject(", - string(paramenterName), - ", ", - paramenterName, - "Decoder)", - semicolon(kt))); - } - } - arguments.accept(paramenterName); - break; - } - } - return statements; - } - - private void controllerVar(boolean kt, List buffer, int indent) { - buffer.add(statement(indent(indent), var(kt), "c = this.factory.apply(ctx)", semicolon(kt))); - } - - public String getGeneratedName() { - return generatedName; - } - - public void setGeneratedName(String generatedName) { - this.generatedName = generatedName; - } - - public MvcRoute addHttpMethod(TypeElement annotation) { - String annotationName = annotation.getQualifiedName().toString(); - var annotationMirror = findAnnotationByName(this.method, annotationName); - - // Fallback to the class-level annotation if the method isn't explicitly annotated - if (annotationMirror == null) { - annotationMirror = findAnnotationByName(this.method.getEnclosingElement(), annotationName); - } - - if (annotationMirror == null) { - throw new IllegalArgumentException("Annotation not found: " + annotation); - } - - annotationMap.put(annotation, annotationMirror); - - var httpMethod = HttpMethod.findByAnnotationName(annotationName); - if (httpMethod == HttpMethod.tRPC) { - this.isTrpc = true; - } - if (httpMethod == HttpMethod.JSON_RPC) { - this.isJsonRpc = true; - } - return this; - } - - public MvcRouter getRouter() { - return router; - } - - public List getParameters(boolean skipCoroutine) { - return parameters.stream() - .filter(type -> !skipCoroutine || !type.getType().is("kotlin.coroutines.Continuation")) - .toList(); - } - - public ExecutableElement getMethod() { - return method; - } - - public List getRawParameterTypes(boolean skipCoroutine) { - return getParameters(skipCoroutine).stream() - .map(MvcParameter::getType) - .map(TypeDefinition::getRawType) - .map(TypeMirror::toString) - .map(it -> type(router.isKt(), it)) - .toList(); - } - - public List getJavaMethodSignature(boolean kt) { - return getParameters(false).stream() - .map( - it -> { - var type = it.getType(); - if (kt && type.isPrimitive()) { - return type(kt, type.getRawType().toString()); - } - return type.getRawType().toString(); - }) - .map(it -> it + clazz(kt)) - .toList(); - } - - public String getMethodName() { - return getMethod().getSimpleName().toString(); - } - - public String getJsonRpcMethodName() { - var annotation = AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.JsonRpc"); - if (annotation != null) { - var val = - AnnotationSupport.findAnnotationValue(annotation, VALUE).stream().findFirst().orElse(""); - if (!val.isEmpty()) return val; - } - return getMethodName(); - } - - public boolean isJsonRpc() { - return isJsonRpc; - } - - @Override - public int hashCode() { - return Objects.hash(method.toString(), isTrpc, isJsonRpc); - } - - @Override - public boolean equals(Object obj) { - if (obj instanceof MvcRoute that) { - return this.method.toString().equals(that.method.toString()) - && this.isTrpc == that.isTrpc - && this.isJsonRpc == that.isJsonRpc; - } - return false; - } - - @Override - public String toString() { - StringBuilder buffer = new StringBuilder(); - for (var e : annotationMap.entrySet()) { - var attributes = e.getValue().getElementValues(); - buffer.append("@").append(e.getKey().getSimpleName()).append("("); - if (attributes.size() == 1) { - buffer.append(attributes.values().iterator().next().getValue()); - } else { - buffer.append(attributes); - } - buffer.append(") "); - } - buffer - .append(method.getSimpleName()) - .append("(") - .append(String.join(", ", getRawParameterTypes(true))) - .append("): ") - .append(getReturnType()); - return buffer.toString(); - } - - private Optional dispatch() { - var dispatch = dispatch(method); - return dispatch.isEmpty() ? dispatch(router.getTargetType()) : dispatch; - } - - private Optional dispatch(Element element) { - return ofNullable(findAnnotationByName(element, "io.jooby.annotation.Dispatch")) - .map(it -> findAnnotationValue(it, VALUE).stream().findFirst().orElse("worker")); - } - - private Optional mediaType(Function> lookup) { - var scopes = List.of(method, router.getTargetType()); - var i = 0; - var types = Collections.emptyList(); - while (types.isEmpty() && i < scopes.size()) { - types = lookup.apply(scopes.get(i++)); - } - if (types.isEmpty()) { - return Optional.empty(); - } - return Optional.of( - types.stream() - .map(type -> CodeBlock.of("io.jooby.MediaType.valueOf(", string(type), ")")) - .collect(Collectors.joining(", ", "java.util.List.of(", ")"))); - } - - public boolean isSuspendFun() { - return suspendFun; - } - - private String javadocComment(boolean kt) { - if (kt) { - return CodeBlock.statement( - "/** See [", router.getTargetType().getSimpleName(), ".", getMethodName(), "]", " */"); - } - return CodeBlock.statement( - "/** See {@link ", - router.getTargetType().getSimpleName(), - "#", - getMethodName(), - "(", - String.join(", ", getRawParameterTypes(true)), - ") */"); - } - - public void setUncheckedCast(boolean value) { - this.uncheckedCast = value; - } - - public boolean hasBeanValidation() { - return hasBeanValidation; - } - - private HttpMethod trpcMethod(Element element) { - if (AnnotationSupport.findAnnotationByName(element, "io.jooby.annotation.Trpc.Query") != null) { - return HttpMethod.GET; - } - if (AnnotationSupport.findAnnotationByName(element, "io.jooby.annotation.Trpc.Mutation") - != null) { - return HttpMethod.POST; - } - - var trpc = AnnotationSupport.findAnnotationByName(element, "io.jooby.annotation.Trpc"); - if (trpc != null) { - if (HttpMethod.GET.matches(element)) { - return HttpMethod.GET; - } - if (HttpMethod.POST.matches(element) - || HttpMethod.PUT.matches(element) - || HttpMethod.PATCH.matches(element) - || HttpMethod.DELETE.matches(element)) { - return HttpMethod.POST; - } - throw new IllegalArgumentException( - "tRPC procedure missing HTTP mapping. Method " - + element.getSimpleName() - + "() in " - + element.getEnclosingElement().getSimpleName() - + " is annotated with @Trpc but lacks a valid HTTP method annotation."); - } - return null; - } - - public String trpcPath(Element element) { - var namespace = - Optional.ofNullable( - AnnotationSupport.findAnnotationByName( - element.getEnclosingElement(), "io.jooby.annotation.Trpc")) - .flatMap(it -> findAnnotationValue(it, VALUE).stream().findFirst()) - .map(it -> it + ".") - .orElse(""); - - var procedure = - Stream.of( - "io.jooby.annotation.Trpc.Query", - "io.jooby.annotation.Trpc.Mutation", - "io.jooby.annotation.Trpc") - .map(it -> AnnotationSupport.findAnnotationByName(element, it)) - .filter(Objects::nonNull) - .findFirst() - .flatMap(it -> findAnnotationValue(it, VALUE).stream().findFirst()) - .orElse(element.getSimpleName().toString()); - return Stream.of("trpc", namespace + procedure) - .map(segment -> segment.startsWith("/") ? segment.substring(1) : segment) - .collect(Collectors.joining("/", "/", "")); - } - - private String extractAnnotationValue(MvcRoute route, String annotationName, String attribute) { - var annotation = - io.jooby.internal.apt.AnnotationSupport.findAnnotationByName( - route.getMethod(), annotationName); - if (annotation == null) { - return ""; - } - return io.jooby.internal.apt.AnnotationSupport.findAnnotationValue( - annotation, attribute::equals) - .stream() - .findFirst() - .orElse(""); - } -} diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java deleted file mode 100644 index bf6384b230..0000000000 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java +++ /dev/null @@ -1,1254 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.apt; - -import static io.jooby.internal.apt.AnnotationSupport.VALUE; -import static io.jooby.internal.apt.AnnotationSupport.findAnnotationByName; -import static io.jooby.internal.apt.CodeBlock.*; -import static java.util.Collections.emptyList; - -import java.io.IOException; -import java.util.*; -import java.util.function.BiConsumer; -import java.util.function.Predicate; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import javax.lang.model.element.*; - -public class MvcRouter { - public static final String JAVA = - """ - package ${packageName}; - ${imports} - @io.jooby.annotation.Generated(${className}.class) - public class ${generatedClassName} implements ${implements} { - protected java.util.function.Function factory; - ${constructors} - public ${generatedClassName}(${className} instance) { - setup(ctx -> instance); - } - - public ${generatedClassName}(io.jooby.SneakyThrows.Supplier<${className}> provider) { - setup(ctx -> (${className}) provider.get()); - } - - public ${generatedClassName}(io.jooby.SneakyThrows.Function, ${className}> provider) { - setup(ctx -> provider.apply(${className}.class)); - } - - private void setup(java.util.function.Function factory) { - this.factory = factory; - } - - ${methods} - } - - """; - public static final String KOTLIN = - """ - package ${packageName} - ${imports} - @io.jooby.annotation.Generated(${className}::class) - open class ${generatedClassName} : ${implements} { - private lateinit var factory: java.util.function.Function - - ${constructors} - constructor(instance: ${className}) { setup { instance } } - - constructor(provider: io.jooby.SneakyThrows.Supplier<${className}>) { setup { provider.get() } } - - constructor(provider: (Class<${className}>) -> ${className}) { setup { provider(${className}::class.java) } } - - constructor(provider: io.jooby.SneakyThrows.Function, ${className}>) { setup { provider.apply(${className}::class.java) } } - - private fun setup(factory: java.util.function.Function) { - this.factory = factory - } - ${methods} - } - - """; - - private final MvcContext context; - - /** MVC router class. */ - private final TypeElement clazz; - - /** MVC route methods. */ - private final Map routes = new LinkedHashMap<>(); - - public MvcRouter(MvcContext context, TypeElement clazz) { - this.context = context; - this.clazz = clazz; - - // JSON-RPC Method Discovery Logic - var classJsonRpcAnno = - AnnotationSupport.findAnnotationByName(clazz, "io.jooby.annotation.JsonRpc"); - - List explicitlyAnnotated = new ArrayList<>(); - List allPublicMethods = new ArrayList<>(); - - for (var enclosed : clazz.getEnclosedElements()) { - if (enclosed.getKind() == ElementKind.METHOD) { - var method = (ExecutableElement) enclosed; - var modifiers = method.getModifiers(); - - // Only consider public, non-static, non-abstract methods - if (modifiers.contains(Modifier.PUBLIC) - && !modifiers.contains(Modifier.STATIC) - && !modifiers.contains(Modifier.ABSTRACT)) { - - // Ignore standard Java Object methods - String methodName = method.getSimpleName().toString(); - if (methodName.equals("toString") - || methodName.equals("hashCode") - || methodName.equals("equals") - || methodName.equals("clone")) { - continue; - } - - allPublicMethods.add(method); - var methodJsonRpcAnno = - AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.JsonRpc"); - if (methodJsonRpcAnno != null) { - explicitlyAnnotated.add(method); - } - } - } - } - - if (!explicitlyAnnotated.isEmpty()) { - // Rule 2: If one or more methods are explicitly annotated, ONLY expose those methods. - for (var method : explicitlyAnnotated) { - var methodAnno = - AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.JsonRpc"); - TypeElement annoElement = (TypeElement) methodAnno.getAnnotationType().asElement(); - put(annoElement, method); - } - } else if (classJsonRpcAnno != null) { - // Rule 1: Class is annotated, but no specific methods are. Expose ALL public methods. - var annoElement = (TypeElement) classJsonRpcAnno.getAnnotationType().asElement(); - for (var method : allPublicMethods) { - put(annoElement, method); - } - } - } - - public MvcRouter(TypeElement clazz, MvcRouter parent) { - this.context = parent.context; - this.clazz = clazz; - for (var e : parent.routes.entrySet()) { - this.routes.put(e.getKey(), new MvcRoute(context, this, e.getValue())); - } - } - - public boolean isKt() { - return context - .getProcessingEnvironment() - .getElementUtils() - .getAllAnnotationMirrors(getTargetType()) - .stream() - .anyMatch(it -> it.getAnnotationType().asElement().toString().equals("kotlin.Metadata")); - } - - public TypeElement getTargetType() { - return clazz; - } - - public String getGeneratedType() { - String baseName = getTargetType().getQualifiedName().toString(); - String name = isJsonRpc() ? baseName + "Rpc" : baseName; - return context.generateRouterName(name); - } - - public MvcRouter put(TypeElement httpMethod, ExecutableElement route) { - var isTrpc = - HttpMethod.findByAnnotationName(httpMethod.getQualifiedName().toString()) - == HttpMethod.tRPC; - var isJsonRpc = - HttpMethod.findByAnnotationName(httpMethod.getQualifiedName().toString()) - == HttpMethod.JSON_RPC; - - var routeKey = (isTrpc ? "trpc" : (isJsonRpc ? "jsonrpc" : "")) + route.toString(); - var existing = routes.get(routeKey); - - if (existing == null) { - routes.put(routeKey, new MvcRoute(context, this, route).addHttpMethod(httpMethod)); - } else { - if (existing.getMethod().getEnclosingElement().equals(getTargetType())) { - existing.addHttpMethod(httpMethod); - } else { - // Favor override version of same method - routes.put(routeKey, new MvcRoute(context, this, route).addHttpMethod(httpMethod)); - } - } - return this; - } - - public List getRoutes() { - return routes.values().stream().toList(); - } - - public boolean isAbstract() { - return clazz.getModifiers().contains(Modifier.ABSTRACT); - } - - public String getPackageName() { - var classname = getGeneratedType(); - var pkgEnd = classname.lastIndexOf('.'); - return pkgEnd > 0 ? classname.substring(0, pkgEnd) : ""; - } - - public boolean isJsonRpc() { - return getRoutes().stream().anyMatch(MvcRoute::isJsonRpc); - } - - public String getJsonRpcNamespace() { - var annotation = AnnotationSupport.findAnnotationByName(clazz, "io.jooby.annotation.JsonRpc"); - if (annotation != null) { - return AnnotationSupport.findAnnotationValue(annotation, VALUE).stream() - .findFirst() - .orElse(""); - } - return ""; - } - - public boolean hasRestRoutes() { - return getRoutes().stream().anyMatch(it -> !it.isJsonRpc() && !it.isMcpRoute()); - } - - public boolean hasJsonRpcRoutes() { - return getRoutes().stream().anyMatch(MvcRoute::isJsonRpc); - } - - public String getRestGeneratedType() { - return context.generateRouterName(getTargetType().getQualifiedName().toString()); - } - - public String getRpcGeneratedType() { - return context.generateRouterName(getTargetType().getQualifiedName().toString() + "Rpc"); - } - - public String getRestGeneratedFilename() { - return getRestGeneratedType().replace('.', '/') + (isKt() ? ".kt" : ".java"); - } - - public String getRpcGeneratedFilename() { - return getRpcGeneratedType().replace('.', '/') + (isKt() ? ".kt" : ".java"); - } - - /** - * Generate the controller extension for MVC controller: - * - *

{@code
-   * public class Controller_ implements MvcExtension {
-   * ....
-   * }
-   *
-   * }
- * - * @return The source code to write, or null if the controller only contains JSON-RPC routes. - */ - public String getRestSourceCode(Boolean generateKotlin) throws IOException { - var mvcRoutes = - this.routes.values().stream().filter(it -> !it.isJsonRpc() && !it.isMcpRoute()).toList(); - - if (mvcRoutes.isEmpty()) { - return null; // Safety check if called on a JSON-RPC-only controller - } - - var kt = generateKotlin == Boolean.TRUE || isKt(); - var generateTypeName = getTargetType().getSimpleName().toString(); - var generatedClass = context.generateRouterName(generateTypeName); - - var template = kt ? KOTLIN : JAVA; - var suspended = mvcRoutes.stream().filter(MvcRoute::isSuspendFun).toList(); - var noSuspended = mvcRoutes.stream().filter(it -> !it.isSuspendFun()).toList(); - var buffer = new StringBuilder(); - context.generateStaticImports( - null, - (owner, fn) -> - buffer.append( - statement("import ", kt ? "" : "static ", owner, ".", fn, semicolon(kt)))); - var imports = buffer.toString(); - buffer.setLength(0); - - // begin install - if (kt) { - buffer.append(indent(4)).append("@Throws(Exception::class)").append(System.lineSeparator()); - buffer - .append(indent(4)) - .append("override fun install(app: io.jooby.Jooby) {") - .append(System.lineSeparator()); - } else { - buffer - .append(indent(4)) - .append("public void install(io.jooby.Jooby app) throws Exception {") - .append(System.lineSeparator()); - } - if (!suspended.isEmpty()) { - buffer.append(statement(indent(6), "val kooby = app as io.jooby.kt.Kooby")); - buffer.append(statement(indent(6), "kooby.coroutine {")); - suspended.stream() - .flatMap(it -> it.generateMapping(kt).stream()) - .forEach(line -> buffer.append(CodeBlock.indent(8)).append(line)); - trimr(buffer); - buffer.append(System.lineSeparator()).append(statement(indent(6), "}")); - } - noSuspended.stream() - .flatMap(it -> it.generateMapping(kt).stream()) - .forEach(line -> buffer.append(CodeBlock.indent(6)).append(line)); - trimr(buffer); - buffer - .append(System.lineSeparator()) - .append(indent(4)) - .append("}") - .append(System.lineSeparator()) - .append(System.lineSeparator()); - // end install - - mvcRoutes.stream() - .flatMap(it -> it.generateHandlerCall(kt).stream()) - .forEach(line -> buffer.append(CodeBlock.indent(4)).append(line)); - - return template - .replace("${packageName}", getPackageName()) - .replace("${imports}", imports) - .replace("${className}", generateTypeName) - .replace("${generatedClassName}", generatedClass) - .replace("${implements}", "io.jooby.Extension") - .replace("${constructors}", constructors(generatedClass, kt)) - .replace("${methods}", trimr(buffer)); - } - - public String getRpcSourceCode(Boolean generateKotlin) { - var rpcRoutes = getRoutes().stream().filter(MvcRoute::isJsonRpc).toList(); - if (rpcRoutes.isEmpty()) { - return null; - } - - var kt = generateKotlin == Boolean.TRUE || isKt(); - var generateTypeName = getTargetType().getSimpleName().toString(); - var generatedClass = context.generateRouterName(generateTypeName + "Rpc"); - var namespace = getJsonRpcNamespace(); - - var template = kt ? KOTLIN : JAVA; - var buffer = new StringBuilder(); - - context.generateStaticImports( - null, - (owner, fn) -> - buffer.append( - statement("import ", kt ? "" : "static ", owner, ".", fn, semicolon(kt)))); - var imports = buffer.toString(); - buffer.setLength(0); - - List fullMethods = new ArrayList<>(); - for (MvcRoute route : rpcRoutes) { - String routeName = route.getJsonRpcMethodName(); - fullMethods.add(namespace.isEmpty() ? routeName : namespace + "." + routeName); - } - - String methodListString = - fullMethods.stream().map(m -> "\"" + m + "\"").collect(Collectors.joining(", ")); - - if (kt) { - buffer.append(indent(4)).append("@Throws(Exception::class)").append(System.lineSeparator()); - buffer - .append(indent(4)) - .append("override fun install(app: io.jooby.Jooby) {") - .append(System.lineSeparator()); - buffer - .append(indent(6)) - .append("app.services.listOf(io.jooby.rpc.jsonrpc.JsonRpcService::class.java).add(this)") - .append(System.lineSeparator()); - buffer - .append(indent(4)) - .append("}") - .append(System.lineSeparator()) - .append(System.lineSeparator()); - - buffer - .append(indent(4)) - .append("override fun getMethods(): List {") - .append(System.lineSeparator()); - buffer - .append(indent(6)) - .append("return listOf(") - .append(methodListString) - .append(")") - .append(System.lineSeparator()); - buffer - .append(indent(4)) - .append("}") - .append(System.lineSeparator()) - .append(System.lineSeparator()); - - buffer - .append(indent(4)) - .append( - "override fun execute(ctx: io.jooby.Context, req:" - + " io.jooby.rpc.jsonrpc.JsonRpcRequest): Any? {") - .append(System.lineSeparator()); - buffer.append(indent(6)).append("val c = factory.apply(ctx)").append(System.lineSeparator()); - buffer.append(indent(6)).append("val method = req.method").append(System.lineSeparator()); - buffer - .append(indent(6)) - .append("val parser = ctx.require(io.jooby.rpc.jsonrpc.JsonRpcParser::class.java)") - .append(System.lineSeparator()); - buffer.append(indent(6)).append("return when(method) {").append(System.lineSeparator()); - - for (int i = 0; i < rpcRoutes.size(); i++) { - buffer - .append(indent(8)) - .append("\"") - .append(fullMethods.get(i)) - .append("\" -> {") - .append(System.lineSeparator()); - rpcRoutes.get(i).generateJsonRpcDispatchCase(true).forEach(buffer::append); - buffer.append(indent(8)).append("}").append(System.lineSeparator()); - } - - buffer - .append(indent(8)) - .append( - "else -> throw" - + " io.jooby.rpc.jsonrpc.JsonRpcException(io.jooby.rpc.jsonrpc.JsonRpcErrorCode.METHOD_NOT_FOUND," - + " \"Method not found: $method\")") - .append(System.lineSeparator()); - buffer.append(indent(6)).append("}").append(System.lineSeparator()); - buffer.append(indent(4)).append("}").append(System.lineSeparator()); - - } else { - buffer.append(indent(4)).append("@Override").append(System.lineSeparator()); - buffer - .append(indent(4)) - .append("public void install(io.jooby.Jooby app) throws Exception {") - .append(System.lineSeparator()); - buffer - .append(indent(6)) - .append("app.getServices().listOf(io.jooby.rpc.jsonrpc.JsonRpcService.class).add(this);") - .append(System.lineSeparator()); - buffer - .append(indent(4)) - .append("}") - .append(System.lineSeparator()) - .append(System.lineSeparator()); - - buffer.append(indent(4)).append("@Override").append(System.lineSeparator()); - buffer - .append(indent(4)) - .append("public java.util.List getMethods() {") - .append(System.lineSeparator()); - buffer - .append(indent(6)) - .append("return java.util.List.of(") - .append(methodListString) - .append(");") - .append(System.lineSeparator()); - buffer - .append(indent(4)) - .append("}") - .append(System.lineSeparator()) - .append(System.lineSeparator()); - - buffer.append(indent(4)).append("@Override").append(System.lineSeparator()); - buffer - .append(indent(4)) - .append( - "public Object execute(io.jooby.Context ctx, io.jooby.rpc.jsonrpc.JsonRpcRequest req)" - + " throws Exception {") - .append(System.lineSeparator()); - buffer - .append(indent(6)) - .append(generateTypeName) - .append(" c = factory.apply(ctx);") - .append(System.lineSeparator()); - buffer - .append(indent(6)) - .append("String method = req.getMethod();") - .append(System.lineSeparator()); - buffer - .append(indent(6)) - .append( - "io.jooby.rpc.jsonrpc.JsonRpcParser parser =" - + " ctx.require(io.jooby.rpc.jsonrpc.JsonRpcParser.class);") - .append(System.lineSeparator()); - buffer.append(indent(6)).append("switch(method) {").append(System.lineSeparator()); - - for (int i = 0; i < rpcRoutes.size(); i++) { - buffer - .append(indent(8)) - .append("case \"") - .append(fullMethods.get(i)) - .append("\": {") - .append(System.lineSeparator()); - rpcRoutes.get(i).generateJsonRpcDispatchCase(false).forEach(buffer::append); - buffer.append(indent(8)).append("}").append(System.lineSeparator()); - } - - buffer.append(indent(8)).append("default:").append(System.lineSeparator()); - buffer - .append(indent(10)) - .append( - "throw new" - + " io.jooby.rpc.jsonrpc.JsonRpcException(io.jooby.rpc.jsonrpc.JsonRpcErrorCode.METHOD_NOT_FOUND," - + " \"Method not found: \" + method);") - .append(System.lineSeparator()); - buffer.append(indent(6)).append("}").append(System.lineSeparator()); - buffer.append(indent(4)).append("}").append(System.lineSeparator()); - } - - return template - .replace("${packageName}", getPackageName()) - .replace("${imports}", imports) - .replace("${className}", generateTypeName) - .replace("${generatedClassName}", generatedClass) - .replace("${implements}", "io.jooby.rpc.jsonrpc.JsonRpcService, io.jooby.Extension") - .replace("${constructors}", constructors(generatedClass, kt)) - .replace("${methods}", trimr(buffer)); - } - - public boolean hasMcpRoutes() { - return getRoutes().stream().anyMatch(MvcRoute::isMcpRoute); - } - - public String getMcpGeneratedType() { - return context.generateRouterName(getTargetType().getQualifiedName().toString() + "Mcp"); - } - - public String getMcpGeneratedFilename() { - return getMcpGeneratedType().replace('.', '/') + (isKt() ? ".kt" : ".java"); - } - - public String getMcpServerKey() { - var annotation = AnnotationSupport.findAnnotationByName(clazz, "io.jooby.annotation.McpServer"); - if (annotation != null) { - return AnnotationSupport.findAnnotationValue(annotation, VALUE).stream() - .findFirst() - .orElse("default"); - } - return "default"; - } - - public String getMcpSourceCode(Boolean generateKotlin) { - if (!hasMcpRoutes()) { - return null; - } - - boolean kt = generateKotlin == Boolean.TRUE || isKt(); - var generateTypeName = getTargetType().getSimpleName().toString(); - var mcpClassName = context.generateRouterName(generateTypeName + "Mcp"); - var packageName = getPackageName(); - - var template = kt ? KOTLIN : JAVA; - var buffer = new StringBuilder(); - - context.generateStaticImports( - null, - (owner, fn) -> - buffer.append( - statement("import ", kt ? "" : "static ", owner, ".", fn, semicolon(kt)))); - var imports = buffer.toString(); - buffer.setLength(0); - - var tools = getRoutes().stream().filter(MvcRoute::isMcpTool).toList(); - var prompts = getRoutes().stream().filter(MvcRoute::isMcpPrompt).toList(); - // FIXED: Now properly includes Templates so capabilities.resources() activates - var resources = - getRoutes().stream().filter(r -> r.isMcpResource() || r.isMcpResourceTemplate()).toList(); - - // 1. Group Completions by Reference - var completionRoutes = getRoutes().stream().filter(MvcRoute::isMcpCompletion).toList(); - java.util.Map> completionGroups = - new java.util.LinkedHashMap<>(); - for (MvcRoute route : completionRoutes) { - String ref = extractAnnotationValue(route, "io.jooby.annotation.McpCompletion", "value"); - if (ref == null || ref.isEmpty()) { - ref = extractAnnotationValue(route, "io.jooby.annotation.McpCompletion", "ref"); - } - completionGroups.computeIfAbsent(ref, k -> new java.util.ArrayList<>()).add(route); - } - - // Generate JSON Mapper Field - if (kt) { - buffer.append( - statement( - indent(4), - "private lateinit var json: io.modelcontextprotocol.json.McpJsonMapper\n")); - } else { - buffer.append( - statement( - indent(4), - "private io.modelcontextprotocol.json.McpJsonMapper json", - semicolon(kt), - "\n")); - } - - // Generate capabilities() - if (kt) { - buffer.append( - statement( - indent(4), - "override fun capabilities(capabilities:" - + " io.modelcontextprotocol.spec.McpSchema.ServerCapabilities.Builder) {")); - } else { - buffer.append(statement(indent(4), "@Override")); - buffer.append( - statement( - indent(4), - "public void" - + " capabilities(io.modelcontextprotocol.spec.McpSchema.ServerCapabilities.Builder" - + " capabilities) {")); - } - if (!tools.isEmpty()) - buffer.append(statement(indent(6), "capabilities.tools(true)", semicolon(kt))); - if (!prompts.isEmpty()) - buffer.append(statement(indent(6), "capabilities.prompts(true)", semicolon(kt))); - if (!resources.isEmpty()) - buffer.append(statement(indent(6), "capabilities.resources(true, true)", semicolon(kt))); - buffer.append(statement(indent(4), "}\n")); - - // Generate serverName() - String serverName = getMcpServerKey(); - if (kt) { - buffer.append(statement(indent(4), "override fun serverName(): String? {")); - buffer.append(statement(indent(6), "return ", string(serverName))); - } else { - buffer.append(statement(indent(4), "@Override")); - buffer.append(statement(indent(4), "public String serverName() {")); - buffer.append(statement(indent(6), "return ", string(serverName), semicolon(kt))); - } - buffer.append(statement(indent(4), "}\n")); - - // Generate completions() list - if (kt) { - buffer.append( - statement( - indent(4), - "override fun completions():" - + " List" - + " {")); - buffer.append( - statement( - indent(6), - "val completions =" - + " mutableListOf()")); - } else { - buffer.append(statement(indent(4), "@Override")); - buffer.append( - statement( - indent(4), - "public" - + " java.util.List" - + " completions() {")); - buffer.append( - statement( - indent(6), - "var completions = new" - + " java.util.ArrayList()", - semicolon(kt))); - } - - for (String ref : completionGroups.keySet()) { - boolean isResource = ref.contains("://"); - String handlerName = findTargetMethodName(ref) + "CompletionHandler"; - String refObj = - isResource - ? "io.modelcontextprotocol.spec.McpSchema.ResourceReference" - : "io.modelcontextprotocol.spec.McpSchema.PromptReference"; - - if (kt) { - buffer.append( - statement( - indent(6), - "completions.add(io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(", - refObj, - "(", - string(ref), - "), this::", - handlerName, - "))")); - } else { - buffer.append( - statement( - indent(6), - "completions.add(new" - + " io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new" - + " ", - refObj, - "(", - string(ref), - "), this::", - handlerName, - "))", - semicolon(kt))); - } - } - buffer.append(statement(indent(6), "return completions", semicolon(kt))); - buffer.append(statement(indent(4), "}\n")); - - // Generate install() - if (kt) { - buffer.append(statement(indent(4), "@Throws(Exception::class)")); - buffer.append( - statement( - indent(4), - "override fun install(app: io.jooby.Jooby, server:" - + " io.modelcontextprotocol.server.McpSyncServer, json:" - + " io.modelcontextprotocol.json.McpJsonMapper) {")); - buffer.append(statement(indent(6), "this.json = json")); - buffer.append( - statement( - indent(6), - "val mapper = app.require(tools.jackson.databind.ObjectMapper::class.java)")); - if (!tools.isEmpty()) { - buffer.append( - statement( - indent(6), - "val configBuilder =" - + " com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder(com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12," - + " com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON)")); - buffer.append( - statement( - indent(6), - "val schemaGenerator =" - + " com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build())")); - } - } else { - buffer.append(statement(indent(4), "@Override")); - buffer.append( - statement( - indent(4), - "public void install(io.jooby.Jooby app, io.modelcontextprotocol.server.McpSyncServer" - + " server, io.modelcontextprotocol.json.McpJsonMapper json) throws Exception" - + " {")); - buffer.append(statement(indent(6), "this.json = json", semicolon(kt))); - buffer.append( - statement( - indent(6), - "var mapper = app.require(tools.jackson.databind.ObjectMapper.class)", - semicolon(kt))); - if (!tools.isEmpty()) { - buffer.append( - statement( - indent(6), - "var configBuilder = new" - + " com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder(com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12," - + " com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON)", - semicolon(kt))); - buffer.append( - statement( - indent(6), - "var schemaGenerator = new" - + " com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build())", - semicolon(kt))); - } - } - - // FIXED: Filter now properly includes isMcpResourceTemplate() - for (var route : - getRoutes().stream() - .filter( - r -> - r.isMcpTool() - || r.isMcpPrompt() - || r.isMcpResource() - || r.isMcpResourceTemplate()) - .toList()) { - String methodName = route.getMethodName(); - - if (route.isMcpTool()) { - String defArgs = "mapper, schemaGenerator"; - if (kt) { - buffer.append( - statement( - indent(6), - "server.addTool(io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification(", - methodName, - "ToolSpec(", - defArgs, - "), this::", - methodName, - "))\n")); - } else { - buffer.append( - statement( - indent(6), - "server.addTool(new" - + " io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification(", - methodName, - "ToolSpec(", - defArgs, - "), this::", - methodName, - "))", - semicolon(kt), - "\n")); - } - } else if (route.isMcpPrompt()) { - if (kt) { - buffer.append( - statement( - indent(6), - "server.addPrompt(io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification(", - methodName, - "PromptSpec(), this::", - methodName, - "))\n")); - } else { - buffer.append( - statement( - indent(6), - "server.addPrompt(new" - + " io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification(", - methodName, - "PromptSpec(), this::", - methodName, - "))", - semicolon(kt), - "\n")); - } - } else if (route.isMcpResource() || route.isMcpResourceTemplate()) { - // FIXED: Condition now allows templates to execute this block! - boolean isTemplate = route.isMcpResourceTemplate(); - - String specType = - isTemplate ? "SyncResourceTemplateSpecification" : "SyncResourceSpecification"; - String addMethod = isTemplate ? "server.addResourceTemplate(" : "server.addResource("; - String defMethod = isTemplate ? "ResourceTemplateSpec()" : "ResourceSpec()"; - - if (kt) { - buffer.append( - statement( - indent(6), - addMethod, - "io.modelcontextprotocol.server.McpServerFeatures.", - specType, - "(", - methodName, - defMethod, - ", this::", - methodName, - "))\n")); - } else { - buffer.append( - statement( - indent(6), - addMethod, - "new io.modelcontextprotocol.server.McpServerFeatures.", - specType, - "(", - methodName, - defMethod, - ", this::", - methodName, - "))", - semicolon(kt), - "\n")); - } - } - } - buffer.append(statement(indent(4), "}\n")); - - // FIXED: Filter now properly includes isMcpResourceTemplate() - for (MvcRoute route : - getRoutes().stream() - .filter( - r -> - r.isMcpTool() - || r.isMcpPrompt() - || r.isMcpResource() - || r.isMcpResourceTemplate()) - .toList()) { - route.generateMcpDefinitionMethod(kt).forEach(buffer::append); - route.generateMcpHandlerMethod(kt).forEach(buffer::append); - } - - // --- STEP 3: GENERATE THE UNIFIED COMPLETION HANDLERS (THE ROUTER) --- - for (var entry : completionGroups.entrySet()) { - String ref = entry.getKey(); - String handlerName = findTargetMethodName(ref) + "CompletionHandler"; - java.util.List routes = entry.getValue(); - - if (kt) { - buffer.append( - statement( - indent(4), - "private fun ", - handlerName, - "(exchange: io.modelcontextprotocol.server.McpSyncServerExchange, req:" - + " io.modelcontextprotocol.spec.McpSchema.CompleteRequest):" - + " io.modelcontextprotocol.spec.McpSchema.CompleteResult {")); - buffer.append( - statement( - indent(6), - "val ctx =" - + " exchange.transportContext().get(io.jooby.Context::class.java.name)")); - buffer.append(statement(indent(6), "val c = this.factory.apply(ctx)")); - buffer.append(statement(indent(6), "val targetArg = req.argument()?.name() ?: \"\"")); - buffer.append(statement(indent(6), "val typedValue = req.argument()?.value() ?: \"\"")); - buffer.append(statement(indent(6), "return when (targetArg) {")); - } else { - buffer.append( - statement( - indent(4), - "private io.modelcontextprotocol.spec.McpSchema.CompleteResult ", - handlerName, - "(io.modelcontextprotocol.server.McpSyncServerExchange exchange," - + " io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) {")); - buffer.append( - statement( - indent(6), - "var ctx = (io.jooby.Context) exchange.transportContext().get(\"CTX\")", - semicolon(kt))); - buffer.append(statement(indent(6), "var c = this.factory.apply(ctx)", semicolon(kt))); - buffer.append( - statement( - indent(6), - "var targetArg = req.argument() != null ? req.argument().name() : \"\"", - semicolon(kt))); - buffer.append( - statement( - indent(6), - "var typedValue = req.argument() != null ? req.argument().value() : \"\"", - semicolon(kt))); - buffer.append(statement(indent(6), "return switch (targetArg) {")); - } - - for (MvcRoute route : routes) { - String targetArgName = null; - java.util.List invokeArgs = new java.util.ArrayList<>(); - - for (var param : route.getParameters(false)) { - String type = param.getType().getRawType().toString(); - if (type.equals("io.jooby.Context")) { - invokeArgs.add("ctx"); - } else if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange")) { - invokeArgs.add("exchange"); - } else { - targetArgName = param.getMcpName(); - invokeArgs.add("typedValue"); - } - } - - if (targetArgName == null) continue; - - if (kt) { - buffer.append(statement(indent(8), string(targetArgName), " -> {")); - buffer.append( - statement( - indent(10), - "val result = c.", - route.getMethodName(), - "(", - String.join(", ", invokeArgs), - ")")); - buffer.append( - statement(indent(10), "io.jooby.mcp.McpResult(this.json).toCompleteResult(result)")); - buffer.append(statement(indent(8), "}")); - } else { - buffer.append(statement(indent(8), "case ", string(targetArgName), " -> {")); - buffer.append( - statement( - indent(10), - "var result = c.", - route.getMethodName(), - "(", - String.join(", ", invokeArgs), - ")", - semicolon(kt))); - buffer.append( - statement( - indent(10), - "yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result)", - semicolon(kt))); - buffer.append(statement(indent(8), "}")); - } - } - - // Default fallback returning the empty list - if (kt) { - buffer.append( - statement( - indent(8), - "else -> io.jooby.mcp.McpResult(this.json).toCompleteResult(emptyList())")); - buffer.append(statement(indent(6), "}")); - } else { - buffer.append( - statement( - indent(8), - "default -> new" - + " io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of())", - semicolon(kt))); - buffer.append( - statement( - indent(6), - "}", - semicolon(kt))); // Note: The semicolon here closes the return statement! - } - buffer.append(statement(indent(4), "}\n")); - } - - return template - .replace("${packageName}", packageName) - .replace("${imports}", imports) - .replace("${className}", generateTypeName) - .replace("${generatedClassName}", mcpClassName) - .replace("${implements}", "io.jooby.mcp.McpService") - .replace("${constructors}", constructors(mcpClassName, kt)) - .replace("${methods}", trimr(buffer)); - } - - private String findTargetMethodName(String ref) { - for (MvcRoute route : getRoutes()) { - if (route.isMcpPrompt()) { - String name = extractAnnotationValue(route, "io.jooby.annotation.McpPrompt", "name"); - if (name == null || name.isEmpty()) name = route.getMethodName(); - if (ref.equals(name)) return route.getMethodName(); - } else if (route.isMcpResource() || route.isMcpResourceTemplate()) { - // Now checks BOTH route types, but only reads from @McpResource - String uri = extractAnnotationValue(route, "io.jooby.annotation.McpResource", "value"); - if (ref.equals(uri)) return route.getMethodName(); - } - } - return "mcpTarget" + Math.abs(ref.hashCode()); - } - - private StringBuilder trimr(StringBuilder buffer) { - var i = buffer.length() - 1; - while (i > 0 && Character.isWhitespace(buffer.charAt(i))) { - buffer.deleteCharAt(i); - i = buffer.length() - 1; - } - return buffer; - } - - private String extractAnnotationValue(MvcRoute route, String annotationName, String attribute) { - var annotation = - io.jooby.internal.apt.AnnotationSupport.findAnnotationByName( - route.getMethod(), annotationName); - if (annotation == null) { - return ""; - } - return io.jooby.internal.apt.AnnotationSupport.findAnnotationValue( - annotation, attribute::equals) - .stream() - .findFirst() - .orElse(""); - } - - private StringBuilder constructors(String generatedName, boolean kt) { - var constructors = - getTargetType().getEnclosedElements().stream() - .filter( - it -> - it.getKind() == ElementKind.CONSTRUCTOR - && it.getModifiers().contains(Modifier.PUBLIC)) - .map(ExecutableElement.class::cast) - .toList(); - var targetType = getTargetType().getSimpleName(); - var buffer = new StringBuilder(); - buffer.append(System.lineSeparator()); - // Inject could be at constructor or field level. - var injectConstructor = - constructors.stream().filter(hasInjectAnnotation()).findFirst().orElse(null); - var inject = injectConstructor != null || hasInjectAnnotation(getTargetType()); - final var defaultConstructor = - constructors.stream().filter(it -> it.getParameters().isEmpty()).findFirst().orElse(null); - if (inject) { - constructor( - generatedName, - kt, - kt ? ":" : null, - buffer, - List.of(), - (output, params) -> { - output - .append("this(") - .append(targetType) - .append(kt ? "::class" : ".class") - .append(")") - .append(semicolon(kt)) - .append(System.lineSeparator()); - }); - } else { - if (defaultConstructor != null) { - constructor( - generatedName, - kt, - kt ? ":" : null, - buffer, - List.of(), - (output, params) -> { - if (kt) { - output - .append("this(") - .append(targetType) - .append("())") - .append(semicolon(true)) - .append(System.lineSeparator()); - } else { - output - .append("this(") - .append("io.jooby.SneakyThrows.singleton(") - .append(targetType) - .append("::new))") - .append(semicolon(false)) - .append(System.lineSeparator()); - } - }); - } - } - var skip = - Stream.of(injectConstructor, defaultConstructor) - .filter(Objects::nonNull) - .collect(Collectors.toSet()); - for (ExecutableElement constructor : constructors) { - if (!skip.contains(constructor)) { - constructor( - generatedName, - kt, - kt ? ":" : null, - buffer, - constructor.getParameters().stream() - .map(it -> Map.entry(it.asType(), it.getSimpleName().toString())) - .toList(), - (output, params) -> { - var separator = ", "; - output.append("this(").append(kt ? "" : "new ").append(targetType).append("("); - params.forEach(e -> output.append(e.getValue()).append(separator)); - output.setLength(output.length() - separator.length()); - output.append("))").append(semicolon(kt)).append(System.lineSeparator()); - }); - } - } - - if (inject) { - if (kt) { - constructor( - generatedName, - true, - "{", - buffer, - List.of(Map.entry("kotlin.reflect.KClass<" + targetType + ">", "type")), - (output, params) -> { - output - .append("setup { ctx -> ctx.require<") - .append(targetType) - .append(">(type.java)") - .append(" }") - .append(System.lineSeparator()); - }); - } else { - constructor( - generatedName, - false, - null, - buffer, - List.of(Map.entry("Class<" + targetType + ">", "type")), - (output, params) -> { - output - .append("setup(") - .append("ctx -> ctx.require(type)") - .append(")") - .append(";") - .append(System.lineSeparator()); - }); - } - } - - return trimr(buffer).append(System.lineSeparator()); - } - - private boolean hasInjectAnnotation(TypeElement targetClass) { - var inject = false; - while (!inject && !targetClass.toString().equals("java.lang.Object")) { - // Look up at field/setter injection - inject = targetClass.getEnclosedElements().stream().anyMatch(hasInjectAnnotation()); - targetClass = - (TypeElement) - context - .getProcessingEnvironment() - .getTypeUtils() - .asElement(targetClass.getSuperclass()); - } - return inject; - } - - private static Predicate hasInjectAnnotation() { - var injectAnnotations = - Set.of("javax.inject.Inject", "jakarta.inject.Inject", "com.google.inject.Inject"); - return it -> - injectAnnotations.stream() - .anyMatch(annotation -> findAnnotationByName(it, annotation) != null); - } - - private void constructor( - String generatedName, - boolean kt, - String ktBody, - StringBuilder buffer, - List> parameters, - BiConsumer>> body) { - buffer.append(indent(4)); - if (kt) { - buffer.append("constructor").append("("); - } else { - buffer.append("public ").append(generatedName).append("("); - } - var separator = ", "; - parameters.forEach( - e -> { - if (kt) { - buffer.append(e.getValue()).append(": ").append(e.getKey()).append(separator); - } else { - buffer.append(e.getKey()).append(" ").append(e.getValue()).append(separator); - } - }); - if (!parameters.isEmpty()) { - buffer.setLength(buffer.length() - separator.length()); - } - buffer.append(")"); - if (!kt) { - buffer.append(" {").append(System.lineSeparator()); - buffer.append(indent(6)); - } else { - buffer.append(" ").append(ktBody).append(" "); - } - body.accept(buffer, parameters); - if (!kt || "{".equals(ktBody)) { - buffer.append(indent(4)).append("}"); - } - buffer.append(System.lineSeparator()).append(System.lineSeparator()); - } - - @Override - public String toString() { - StringBuilder buffer = new StringBuilder(); - var annotations = Optional.ofNullable(clazz.getAnnotationMirrors()).orElse(emptyList()); - annotations.forEach( - annotation -> { - buffer - .append("@") - .append(annotation.getAnnotationType().asElement().getSimpleName()) - .append("("); - buffer.append(annotation.getElementValues()).append(") "); - }); - buffer.append(clazz.asType().toString()).append(" {\n"); - routes.forEach( - (httpMethod, route) -> { - buffer.append(" ").append(route).append("\n"); - }); - buffer.append("}"); - return buffer.toString(); - } - - public boolean hasBeanValidation() { - return getRoutes().stream().anyMatch(MvcRoute::hasBeanValidation); - } -} diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java index a271e57796..f0bc9207af 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java @@ -35,10 +35,6 @@ public RestRoute( this.generatedName = method.getSimpleName().toString(); } - public TypeElement getHttpMethodAnnotation() { - return httpMethodAnnotation; - } - public String getGeneratedName() { return generatedName; } @@ -47,13 +43,6 @@ public void setGeneratedName(String generatedName) { this.generatedName = generatedName; } - static String leadingSlash(String path) { - if (path == null || path.isEmpty() || path.equals("/")) { - return "/"; - } - return path.charAt(0) == '/' ? path : "/" + path; - } - private String methodReference(boolean kt, String thisRef, String methodName) { if (kt) { var generics = returnType.getArgumentsString(kt, true, Set.of(TypeKind.TYPEVAR)); @@ -120,7 +109,6 @@ public List generateMapping(boolean kt, String routerName, boolean isLas HttpMethod.findByAnnotationName(httpMethodAnnotation.getQualifiedName().toString()); var dslMethod = httpMethodAnnotation.getSimpleName().toString().toLowerCase(); var paths = context.path(router.getTargetType(), method, httpMethodAnnotation); - var targetMethod = methodName; var thisRef = isSuspendFun() ? "this@" + context.generateRouterName(routerName) + "::" : "this::"; @@ -136,7 +124,7 @@ public List generateMapping(boolean kt, String routerName, boolean isLas string(leadingSlash(path)), ", ", context.pipeline( - getReturnType().getRawType(), methodReference(kt, thisRef, targetMethod)))); + getReturnType().getRawType(), methodReference(kt, thisRef, methodName)))); if (context.nonBlocking(getReturnType().getRawType()) || isSuspendFun()) { block.add(statement(indent(2), ".setNonBlocking(true)")); @@ -176,7 +164,7 @@ public List generateMapping(boolean kt, String routerName, boolean isLas semicolon(kt), lineSep)); } else { - var lastStatement = block.get(block.size() - 1); + var lastStatement = block.getLast(); if (lastStatement.endsWith(lineSeparator())) { lastStatement = lastStatement.substring(0, lastStatement.length() - lineSeparator().length()); @@ -204,8 +192,8 @@ public List generateHandlerCall(boolean kt) { customReturnType.isProjection() || customReturnType.is(Types.PROJECTED); // 1. Create separate variables for the generated HTTP handler's signature - String handlerTypeGenerics = returnTypeGenerics; - String handlerTypeString = returnTypeString; + var handlerTypeGenerics = returnTypeGenerics; + var handlerTypeString = returnTypeString; // 2. ONLY modify the signature if we need to wrap a NON-projected type if (projection != null && !isProjectedReturnType) { @@ -215,7 +203,6 @@ public List generateHandlerCall(boolean kt) { methodCallHeader( kt, - "ctx", methodName, buffer, handlerTypeGenerics, @@ -248,13 +235,13 @@ public List generateHandlerCall(boolean kt) { ")", semicolon(kt))); - String call = buildMethodCall(kt, paramList.toString(), false, false); + String call = makeCall(kt, paramList.toString(), false, false); buffer.add(statement(indent(controllerIndent), call, semicolon(kt))); buffer.add( statement(indent(controllerIndent), "return ctx.getResponseCode()", semicolon(kt))); } else if (returnType.is("io.jooby.StatusCode")) { - String call = buildMethodCall(kt, paramList.toString(), false, false); + var call = makeCall(kt, paramList.toString(), false, false); buffer.add( statement( @@ -263,13 +250,10 @@ public List generateHandlerCall(boolean kt) { statement(indent(controllerIndent), "ctx.setResponseCode(statusCode)", semicolon(kt))); buffer.add(statement(indent(controllerIndent), "return statusCode", semicolon(kt))); } else { + var call = makeCall(kt, paramList.toString(), isProjectedReturnType, isProjectedReturnType); + var nullable = kt && isNullableKotlinReturn(); - // Leverage shared WebRoute logic for casting and type erasure! - String call = - buildMethodCall(kt, paramList.toString(), isProjectedReturnType, isProjectedReturnType); - boolean nullable = kt && isNullableKotlinReturn(); - - // 3. ONLY wrap the call if it's a NON-projected type with a projection string + // ONLY wrap the call if it's a NON-projected type with a projection string if (projection != null && !isProjectedReturnType) { var projected = of(Types.PROJECTED, ".wrap(", call, ").include(", string(projection), ")"); buffer.add( @@ -289,28 +273,24 @@ public List generateHandlerCall(boolean kt) { buffer.add(statement("}", lineSeparator())); if (isUncheckedCast()) { - if (kt) buffer.addFirst(statement("@Suppress(\"UNCHECKED_CAST\")")); - else buffer.addFirst(statement("@SuppressWarnings(\"unchecked\")")); + if (kt) { + buffer.addFirst(statement("@Suppress(\"UNCHECKED_CAST\")")); + } else { + buffer.addFirst(statement("@SuppressWarnings(\"unchecked\")")); + } } return buffer; } - private boolean methodCallHeader( + private void methodCallHeader( boolean kt, - String contextVarname, String methodName, ArrayList buffer, String returnTypeGenerics, String returnTypeString, boolean throwsException) { - var nullable = false; if (kt) { - nullable = - method.getAnnotationMirrors().stream() - .map(javax.lang.model.element.AnnotationMirror::getAnnotationType) - .map(java.util.Objects::toString) - .anyMatch(AnnotationSupport.NULLABLE); if (throwsException) buffer.add(statement("@Throws(Exception::class)")); if (isSuspendFun()) { @@ -323,16 +303,14 @@ private boolean methodCallHeader( "(handler: io.jooby.kt.HandlerContext): ", returnTypeString, " {")); - buffer.add(statement(indent(2), "val ", contextVarname, " = handler.ctx")); + buffer.add(statement(indent(2), "val ctx = handler.ctx")); } else { buffer.add( statement( "fun ", returnTypeGenerics, methodName, - "(", - contextVarname, - ": io.jooby.Context): ", + "(ctx: io.jooby.Context): ", returnTypeString, " {")); } @@ -344,15 +322,12 @@ private boolean methodCallHeader( returnTypeString, " ", methodName, - "(io.jooby.Context ", - contextVarname, - ") ", + "(io.jooby.Context ctx)", throwsException ? "throws Exception {" : "{")); } - return nullable; } - public String getProjection() { + private String getProjection() { var project = AnnotationSupport.findAnnotationByName(method, Types.PROJECT); if (project != null) { return AnnotationSupport.findAnnotationValue(project, VALUE).stream() diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRouter.java index aca3e6d82b..19ac2cfabe 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRouter.java @@ -15,18 +15,17 @@ import javax.lang.model.element.TypeElement; public class RestRouter extends WebRouter { - public RestRouter(MvcContext context, TypeElement clazz) { super(context, clazz); } public static RestRouter parse(MvcContext context, TypeElement controller) { - RestRouter router = new RestRouter(context, controller); + var router = new RestRouter(context, controller); - for (TypeElement type : context.superTypes(controller)) { + for (var type : context.superTypes(controller)) { for (var enclosed : type.getEnclosedElements()) { if (enclosed.getKind() == ElementKind.METHOD) { - ExecutableElement method = (ExecutableElement) enclosed; + var method = (ExecutableElement) enclosed; // Ignore abstract methods if (method.getModifiers().contains(javax.lang.model.element.Modifier.ABSTRACT)) { @@ -34,19 +33,11 @@ public static RestRouter parse(MvcContext context, TypeElement controller) { } for (var annoMirror : method.getAnnotationMirrors()) { - TypeElement annoElement = (TypeElement) annoMirror.getAnnotationType().asElement(); - String annoName = annoElement.getQualifiedName().toString(); - - // Explicitly ignore RPC annotations so they don't generate invalid REST routes - if (annoName.startsWith("io.jooby.annotation.Trpc") - || annoName.equals("io.jooby.annotation.JsonRpc") - || annoName.startsWith("io.jooby.annotation.Mcp")) { - continue; - } + var annoElement = (TypeElement) annoMirror.getAnnotationType().asElement(); if (HttpMethod.hasAnnotation(annoElement)) { - RestRoute route = new RestRoute(router, method, annoElement); - String uniqueKey = method.toString() + annoElement.getSimpleName(); + var route = new RestRoute(router, method, annoElement); + var uniqueKey = method.toString() + annoElement.getSimpleName(); router.routes.putIfAbsent(uniqueKey, route); } } @@ -80,9 +71,7 @@ public String getGeneratedType() { } @Override - public String getSourceCode(Boolean generateKotlin) throws IOException { - if (isEmpty()) return null; - + public String toSourceCode(Boolean generateKotlin) throws IOException { boolean kt = generateKotlin == Boolean.TRUE || isKt(); var generateTypeName = getTargetType().getSimpleName().toString(); var generatedClass = getGeneratedType().substring(getGeneratedType().lastIndexOf('.') + 1); diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRoute.java index 939369edde..57697c57d2 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRoute.java @@ -29,10 +29,6 @@ public void setGeneratedName(String generatedName) { this.generatedName = generatedName; } - public String getGeneratedName() { - return generatedName; - } - private HttpMethod discoverTrpcMethod() { if (AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.Trpc.Query") != null) return HttpMethod.GET; @@ -40,12 +36,12 @@ private HttpMethod discoverTrpcMethod() { return HttpMethod.POST; if (AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.Trpc") != null) { if (HttpMethod.GET.matches(method)) return HttpMethod.GET; - return HttpMethod.POST; // Default fallback for @Trpc missing explicit Query/Mutation mapping + return HttpMethod.POST; } - return null; + throw new IllegalStateException("Unable to find tRPC method: " + method); } - public String trpcPath() { + private String trpcPath() { var namespace = Optional.ofNullable( AnnotationSupport.findAnnotationByName( @@ -98,7 +94,7 @@ public List generateMapping(boolean kt, String routerName) { if (context.nonBlocking(getReturnType().getRawType()) || isSuspendFun()) block.add(statement(indent(2), ".setNonBlocking(true)")); - var lastStatement = block.get(block.size() - 1); + var lastStatement = block.getLast(); block.set( block.size() - 1, lastStatement + semicolon(kt) + System.lineSeparator() + System.lineSeparator()); @@ -257,7 +253,7 @@ public List generateHandlerCall(boolean kt) { // Read parameters optimally for (var parameter : parameters) { - var paramenterName = parameter.getName(); + var parameterName = parameter.getName(); var rawType = parameter.getType().getRawType().toString(); var type = type(kt, parameter.getType().toString()); boolean isNullable = parameter.isNullable(kt); @@ -291,26 +287,26 @@ public List generateHandlerCall(boolean kt) { statement( indent(4), "val ", - paramenterName, + parameterName, " = if (reader.nextIsNull(", - string(paramenterName), + string(parameterName), ")) null else reader.", readName, "(", - string(paramenterName), + string(parameterName), ")")); } else { buffer.add( statement( indent(4), var(kt), - paramenterName, + parameterName, " = reader.nextIsNull(", - string(paramenterName), + string(parameterName), ") ? null : reader.", readName, "(", - string(paramenterName), + string(parameterName), ")", semicolon(kt))); } @@ -319,15 +315,15 @@ public List generateHandlerCall(boolean kt) { statement( indent(4), var(kt), - paramenterName, + parameterName, " = reader.", readName, "(", - string(paramenterName), + string(parameterName), ")", semicolon(kt))); } - paramList.add(paramenterName); + paramList.add(parameterName); break; case "byte": @@ -355,13 +351,13 @@ public List generateHandlerCall(boolean kt) { statement( indent(4), "val ", - paramenterName, + parameterName, " = if (reader.nextIsNull(", - string(paramenterName), + string(parameterName), ")) null else reader.", readMethod, "(", - string(paramenterName), + string(parameterName), ")", ktCast)); } else { @@ -372,15 +368,15 @@ public List generateHandlerCall(boolean kt) { statement( indent(4), var(kt), - paramenterName, + parameterName, " = reader.nextIsNull(", - string(paramenterName), + string(parameterName), ") ? null : ", javaPrefix, "reader.", readMethod, "(", - string(paramenterName), + string(parameterName), ")", javaSuffix, semicolon(kt))); @@ -398,11 +394,11 @@ public List generateHandlerCall(boolean kt) { statement( indent(4), var(kt), - paramenterName, + parameterName, " = reader.", readMethod, "(", - string(paramenterName), + string(parameterName), ")", ktCast, semicolon(kt))); @@ -414,19 +410,19 @@ public List generateHandlerCall(boolean kt) { statement( indent(4), var(kt), - paramenterName, + parameterName, " = ", javaPrefix, "reader.", readMethod, "(", - string(paramenterName), + string(parameterName), ")", javaSuffix, semicolon(kt))); } } - paramList.add(paramenterName); + paramList.add(parameterName); break; default: @@ -436,7 +432,7 @@ public List generateHandlerCall(boolean kt) { statement( indent(4), "val ", - paramenterName, + parameterName, "Decoder: io.jooby.rpc.trpc.TrpcDecoder<", type, "> = parser.decoder(", @@ -447,24 +443,24 @@ public List generateHandlerCall(boolean kt) { statement( indent(4), "val ", - paramenterName, + parameterName, " = if (reader.nextIsNull(", - string(paramenterName), + string(parameterName), ")) null else reader.nextObject(", - string(paramenterName), + string(parameterName), ", ", - paramenterName, + parameterName, "Decoder)")); } else { buffer.add( statement( indent(4), "val ", - paramenterName, + parameterName, " = reader.nextObject(", - string(paramenterName), + string(parameterName), ", ", - paramenterName, + parameterName, "Decoder)")); } } else { @@ -474,7 +470,7 @@ public List generateHandlerCall(boolean kt) { "io.jooby.rpc.trpc.TrpcDecoder<", genericType, "> ", - paramenterName, + parameterName, "Decoder = parser.decoder(", parameter.getType().toSourceCode(kt), ")", @@ -485,13 +481,13 @@ public List generateHandlerCall(boolean kt) { indent(4), type, " ", - paramenterName, + parameterName, " = reader.nextIsNull(", - string(paramenterName), + string(parameterName), ") ? null : reader.nextObject(", - string(paramenterName), + string(parameterName), ", ", - paramenterName, + parameterName, "Decoder)", semicolon(false))); } else { @@ -500,16 +496,16 @@ public List generateHandlerCall(boolean kt) { indent(4), type, " ", - paramenterName, + parameterName, " = reader.nextObject(", - string(paramenterName), + string(parameterName), ", ", - paramenterName, + parameterName, "Decoder)", semicolon(false))); } } - paramList.add(paramenterName); + paramList.add(parameterName); break; } } @@ -518,10 +514,9 @@ public List generateHandlerCall(boolean kt) { buffer.add( statement(indent(controllerIndent), var(kt), "c = this.factory.apply(ctx)", semicolon(kt))); - // Leverage shared WebRoute logic for casting and type erasure! // Pass 'true' for isRpcWrapper so it safely casts List to List - String call = buildMethodCall(kt, paramList.toString(), false, true); - boolean nullable = kt && isNullableKotlinReturn(); + var call = makeCall(kt, paramList.toString(), false, true); + var nullable = kt && isNullableKotlinReturn(); if (reactive != null) { if (isReactiveVoid) { diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRouter.java index af0edbf978..9c1ae474e6 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRouter.java @@ -73,13 +73,11 @@ public static TrpcRouter parse(MvcContext context, TypeElement controller) { @Override public String getGeneratedType() { - return context.generateRouterName(getTargetType().getQualifiedName().toString() + "Trpc"); + return context.generateRouterName(getTargetType().getQualifiedName() + "Trpc"); } @Override - public String getSourceCode(Boolean generateKotlin) throws IOException { - if (isEmpty()) return null; - + public String toSourceCode(Boolean generateKotlin) throws IOException { boolean kt = generateKotlin == Boolean.TRUE || isKt(); var generateTypeName = getTargetType().getSimpleName().toString(); var generatedClass = getGeneratedType().substring(getGeneratedType().lastIndexOf('.') + 1); diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRoute.java index d0289c311f..de20026e58 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRoute.java @@ -37,7 +37,7 @@ public WebRoute(WebRouter router, ExecutableElement method) { this.hasBeanValidation = parameters.stream().anyMatch(MvcParameter::isRequireBeanValidation); this.suspendFun = !parameters.isEmpty() - && parameters.get(parameters.size() - 1).getType().is("kotlin.coroutines.Continuation"); + && parameters.getLast().getType().is("kotlin.coroutines.Continuation"); this.returnType = new TypeDefinition( context.getProcessingEnvironment().getTypeUtils(), method.getReturnType()); @@ -73,6 +73,13 @@ public List getParameters(boolean skipCoroutine) { .toList(); } + static String leadingSlash(String path) { + if (path == null || path.isEmpty() || path.equals("/")) { + return "/"; + } + return path.charAt(0) == '/' ? path : "/" + path; + } + public TypeDefinition getReturnType() { var processingEnv = context.getProcessingEnvironment(); var types = processingEnv.getTypeUtils(); @@ -81,7 +88,7 @@ public TypeDefinition getReturnType() { if (returnType.isVoid()) { return new TypeDefinition(types, elements.getTypeElement("io.jooby.StatusCode").asType()); } else if (isSuspendFun()) { - var continuation = parameters.get(parameters.size() - 1).getType(); + var continuation = parameters.getLast().getType(); if (!continuation.getArguments().isEmpty()) { var continuationReturnType = continuation.getArguments().get(0).getType(); if (continuationReturnType instanceof WildcardType wildcardType) { @@ -125,7 +132,7 @@ public void setUncheckedCast(boolean value) { this.uncheckedCast = value; } - public List getJavaMethodSignature(boolean kt) { + protected List getJavaMethodSignature(boolean kt) { return getParameters(false).stream() .map( it -> { @@ -139,14 +146,14 @@ public List getJavaMethodSignature(boolean kt) { .toList(); } - public boolean isNullableKotlinReturn() { + protected boolean isNullableKotlinReturn() { return method.getAnnotationMirrors().stream() .map(javax.lang.model.element.AnnotationMirror::getAnnotationType) .map(java.util.Objects::toString) .anyMatch(AnnotationSupport.NULLABLE); } - protected String buildMethodCall( + protected String makeCall( boolean kt, String paramList, boolean preventCast, boolean isRpcWrapper) { var customReturnType = getReturnType(); var castStr = diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRouter.java index 5b7b5af74e..c220fd1ce9 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRouter.java @@ -87,7 +87,7 @@ public WebRouter(MvcContext context, TypeElement clazz) { public abstract String getGeneratedType(); - public abstract String getSourceCode(Boolean generateKotlin) throws IOException; + public abstract String toSourceCode(Boolean generateKotlin) throws IOException; public String getGeneratedFilename() { return getGeneratedType().replace('.', '/') + (isKt() ? ".kt" : ".java"); diff --git a/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java b/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java index 6770182f7d..f18b4e9521 100644 --- a/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java +++ b/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java @@ -80,7 +80,7 @@ protected void onGeneratedSource(String classname, JavaFileObject source) { javaFiles.put(classname, source); try { // Generate kotlin source code inside the compiler scope... avoid false positive errors - kotlinFiles.put(classname, context.getRouters().get(0).getSourceCode(true)); + kotlinFiles.put(classname, context.getRouters().get(0).toSourceCode(true)); } catch (IOException e) { SneakyThrows.propagate(e); } From 6c9e9e52abed91296d9f340c079e122d01eaf420 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Thu, 26 Mar 2026 12:18:11 -0300 Subject: [PATCH 11/37] build: finish cleanup of available router/route --- .../io/jooby/internal/apt/HttpMethod.java | 4 - .../io/jooby/internal/apt/JsonRpcRouter.java | 4 +- .../java/io/jooby/internal/apt/McpRoute.java | 40 ++--- .../java/io/jooby/internal/apt/McpRouter.java | 138 ++++++++---------- .../java/io/jooby/internal/apt/RestRoute.java | 7 +- .../src/test/java/tests/i3830/Issue3830.java | 1 + 6 files changed, 87 insertions(+), 107 deletions(-) diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java index 6c4052e61b..6cefe3a664 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java @@ -37,10 +37,6 @@ public enum HttpMethod implements AnnotationSupport { this.annotations = packageList.stream().map(it -> it + "." + name()).toList(); } - HttpMethod(List annotations) { - this.annotations = annotations; - } - public List path(Element element) { var path = annotations.stream() diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRouter.java index 62cde61120..4679d43e0b 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRouter.java @@ -178,9 +178,7 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { buffer.append(statement(indent(6), "var method = req.getMethod();")); buffer.append( statement( - indent(6), - "io.jooby.rpc.jsonrpc.JsonRpcParser parser =" - + " ctx.require(io.jooby.rpc.jsonrpc.JsonRpcParser.class);")); + indent(6), "var parser = ctx.require(io.jooby.rpc.jsonrpc.JsonRpcParser.class);")); buffer.append(statement(indent(6), "switch(method) {")); for (int i = 0; i < getRoutes().size(); i++) { diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java index 636e183e8a..5ea1f122e5 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java @@ -135,8 +135,8 @@ public List generateMcpDefinitionMethod(boolean kt) { indent(6), "var req = schema.putArray(", string("required"), ")", semicolon(kt))); } - for (MvcParameter param : getParameters(false)) { - String type = param.getType().getRawType().toString(); + for (var param : getParameters(true)) { + var type = param.getType().getRawType().toString(); if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange") || type.equals("io.jooby.Context")) continue; @@ -290,13 +290,13 @@ public List generateMcpDefinitionMethod(boolean kt) { semicolon(kt))); } - for (MvcParameter param : getParameters(false)) { - String type = param.getType().getRawType().toString(); + for (var param : getParameters(true)) { + var type = param.getType().getRawType().toString(); if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange") || type.equals("io.jooby.Context")) continue; - String mcpName = param.getMcpName(); - boolean isRequired = !param.isNullable(kt); + var mcpName = param.getMcpName(); + var isRequired = !param.isNullable(kt); if (kt) { buffer.add( @@ -343,13 +343,13 @@ public List generateMcpDefinitionMethod(boolean kt) { buffer.add(statement(indent(4), "}\n")); } else if (isMcpResource() || isMcpResourceTemplate()) { - String uri = extractAnnotationValue("io.jooby.annotation.McpResource", "value"); - String name = extractAnnotationValue("io.jooby.annotation.McpResource", "name"); + var uri = extractAnnotationValue("io.jooby.annotation.McpResource", "value"); + var name = extractAnnotationValue("io.jooby.annotation.McpResource", "name"); if (name.isEmpty()) name = getMethodName(); - String description = extractAnnotationValue("io.jooby.annotation.McpResource", "description"); + var description = extractAnnotationValue("io.jooby.annotation.McpResource", "description"); - boolean isTemplate = isMcpResourceTemplate(); - String specType = isTemplate ? "ResourceTemplate" : "Resource"; + var isTemplate = isMcpResourceTemplate(); + var specType = isTemplate ? "ResourceTemplate" : "Resource"; if (kt) { buffer.add( @@ -427,8 +427,6 @@ public List generateMcpDefinitionMethod(boolean kt) { } public List generateMcpHandlerMethod(boolean kt) { - List buffer = new ArrayList<>(); - String reqType = ""; String resType = ""; String toMethod = ""; @@ -446,9 +444,11 @@ public List generateMcpHandlerMethod(boolean kt) { resType = "ReadResourceResult"; toMethod = "toResourceResult"; } else { - return buffer; + return List.of(); } + List buffer = new ArrayList<>(); + if (kt) { buffer.add( statement( @@ -544,11 +544,11 @@ public List generateMcpHandlerMethod(boolean kt) { statement(indent(6), kt ? "val " : "var ", "c = this.factory.apply(ctx)", semicolon(kt))); List javaParamNames = new ArrayList<>(); - for (MvcParameter param : getParameters(false)) { - String javaName = param.getName(); - String mcpName = param.getMcpName(); - String type = param.getType().getRawType().toString(); - boolean isNullable = param.isNullable(kt); + for (var param : getParameters(true)) { + var javaName = param.getName(); + var mcpName = param.getMcpName(); + var type = param.getType().getRawType().toString(); + var isNullable = param.isNullable(kt); javaParamNames.add(javaName); if (type.equals("io.jooby.Context")) { @@ -642,7 +642,7 @@ public List generateMcpHandlerMethod(boolean kt) { } } - String methodCall = "c." + getMethodName() + "(" + String.join(", ", javaParamNames) + ")"; + var methodCall = "c." + getMethodName() + "(" + String.join(", ", javaParamNames) + ")"; if (getReturnType().isVoid()) { buffer.add(statement(indent(6), methodCall, semicolon(kt))); diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java index 9afbfce844..407c86645b 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java @@ -9,6 +9,7 @@ import static io.jooby.internal.apt.CodeBlock.*; import java.io.IOException; +import java.util.ArrayList; import javax.lang.model.element.ElementKind; import javax.lang.model.element.ExecutableElement; @@ -21,10 +22,10 @@ public McpRouter(MvcContext context, TypeElement clazz) { } public static McpRouter parse(MvcContext context, TypeElement controller) { - McpRouter router = new McpRouter(context, controller); + var router = new McpRouter(context, controller); for (var enclosed : controller.getEnclosedElements()) { if (enclosed.getKind() == ElementKind.METHOD) { - McpRoute route = new McpRoute(router, (ExecutableElement) enclosed); + var route = new McpRoute(router, (ExecutableElement) enclosed); if (route.isMcpTool() || route.isMcpPrompt() || route.isMcpResource() @@ -42,7 +43,7 @@ public String getGeneratedType() { return context.generateRouterName(getTargetType().getQualifiedName() + "Mcp"); } - public String getMcpServerKey() { + private String getMcpServerKey() { var annotation = AnnotationSupport.findAnnotationByName(clazz, "io.jooby.annotation.McpServer"); if (annotation != null) { return AnnotationSupport.findAnnotationValue(annotation, VALUE).stream() @@ -52,31 +53,43 @@ public String getMcpServerKey() { return "default"; } + /** + * Find completion target must be a prompt or resource. + * + * @param ref Prompt name or resource uri. + * @return Method name. + */ private String findTargetMethodName(String ref) { - for (McpRoute route : getRoutes()) { + for (var route : getRoutes()) { if (route.isMcpPrompt()) { var annotation = AnnotationSupport.findAnnotationByName( route.getMethod(), "io.jooby.annotation.McpPrompt"); - String name = + var name = annotation != null ? AnnotationSupport.findAnnotationValue(annotation, "name"::equals).stream() .findFirst() .orElse("") : ""; - if (name.isEmpty()) name = route.getMethodName(); - if (ref.equals(name)) return route.getMethodName(); + if (name.isEmpty()) { + name = route.getMethodName(); + } + if (ref.equals(name)) { + return route.getMethodName(); + } } else if (route.isMcpResource() || route.isMcpResourceTemplate()) { var annotation = AnnotationSupport.findAnnotationByName( route.getMethod(), "io.jooby.annotation.McpResource"); - String uri = + var uri = annotation != null ? AnnotationSupport.findAnnotationValue(annotation, "value"::equals).stream() .findFirst() .orElse("") : ""; - if (ref.equals(uri)) return route.getMethodName(); + if (ref.equals(uri)) { + return route.getMethodName(); + } } } return "mcpTarget" + Math.abs(ref.hashCode()); @@ -84,9 +97,7 @@ private String findTargetMethodName(String ref) { @Override public String toSourceCode(Boolean generateKotlin) throws IOException { - if (isEmpty()) return null; - - boolean kt = generateKotlin == Boolean.TRUE || isKt(); + var kt = generateKotlin == Boolean.TRUE || isKt(); var generateTypeName = getTargetType().getSimpleName().toString(); var mcpClassName = getGeneratedType().substring(getGeneratedType().lastIndexOf('.') + 1); var packageName = getPackageName(); @@ -108,37 +119,33 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { getRoutes().stream().filter(r -> r.isMcpResource() || r.isMcpResourceTemplate()).toList(); var completionRoutes = getRoutes().stream().filter(McpRoute::isMcpCompletion).toList(); - java.util.Map> completionGroups = - new java.util.LinkedHashMap<>(); - for (McpRoute route : completionRoutes) { + var completionGroups = new java.util.LinkedHashMap>(); + // group completion we need to genereate a single handler for all completion routes + for (var route : completionRoutes) { var annotation = AnnotationSupport.findAnnotationByName( route.getMethod(), "io.jooby.annotation.McpCompletion"); String ref = AnnotationSupport.findAnnotationValue(annotation, "value"::equals).stream() .findFirst() - .orElse(""); + .orElse(null); if (ref == null || ref.isEmpty()) { ref = AnnotationSupport.findAnnotationValue(annotation, "ref"::equals).stream() .findFirst() .orElse(""); } - completionGroups.computeIfAbsent(ref, k -> new java.util.ArrayList<>()).add(route); + completionGroups.computeIfAbsent(ref, k -> new ArrayList<>()).add(route); } if (kt) { buffer.append( statement( - indent(4), - "private lateinit var json: io.modelcontextprotocol.json.McpJsonMapper\n")); + indent(4), "private lateinit var json: io.modelcontextprotocol.json.McpJsonMapper")); } else { buffer.append( statement( - indent(4), - "private io.modelcontextprotocol.json.McpJsonMapper json", - semicolon(kt), - "\n")); + indent(4), "private io.modelcontextprotocol.json.McpJsonMapper json", semicolon(kt))); } if (kt) { @@ -164,7 +171,7 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { buffer.append(statement(indent(6), "capabilities.resources(true, true)", semicolon(kt))); buffer.append(statement(indent(4), "}\n")); - String serverName = getMcpServerKey(); + var serverName = getMcpServerKey(); if (kt) { buffer.append(statement(indent(4), "override fun serverName(): String? {")); buffer.append(statement(indent(6), "return ", string(serverName))); @@ -203,10 +210,10 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { semicolon(kt))); } - for (String ref : completionGroups.keySet()) { - boolean isResource = ref.contains("://"); - String handlerName = findTargetMethodName(ref) + "CompletionHandler"; - String refObj = + for (var ref : completionGroups.keySet()) { + var isResource = ref.contains("://"); + var handlerName = findTargetMethodName(ref) + "CompletionHandler"; + var refObj = isResource ? "io.modelcontextprotocol.spec.McpSchema.ResourceReference" : "io.modelcontextprotocol.spec.McpSchema.PromptReference"; @@ -265,7 +272,7 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { statement( indent(6), "val schemaGenerator =" - + " com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build())\n")); + + " com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build())")); } } else { buffer.append(statement(indent(4), "@Override")); @@ -294,24 +301,17 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { indent(6), "var schemaGenerator = new" + " com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build())", - semicolon(kt), - "\n")); + semicolon(kt))); } } - for (var route : - getRoutes().stream() - .filter( - r -> - r.isMcpTool() - || r.isMcpPrompt() - || r.isMcpResource() - || r.isMcpResourceTemplate()) - .toList()) { - String methodName = route.getMethodName(); + buffer.append(System.lineSeparator()); + + for (var route : getRoutes()) { + var methodName = route.getMethodName(); if (route.isMcpTool()) { - String defArgs = "mapper, schemaGenerator"; + var defArgs = "mapper, schemaGenerator"; if (kt) { buffer.append( statement( @@ -322,7 +322,7 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { defArgs, "), this::", methodName, - "))\n")); + "))")); } else { buffer.append( statement( @@ -334,9 +334,7 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { defArgs, "), this::", methodName, - "))", - semicolon(kt), - "\n")); + "));")); } } else if (route.isMcpPrompt()) { if (kt) { @@ -347,7 +345,7 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { methodName, "PromptSpec(), this::", methodName, - "))\n")); + "))")); } else { buffer.append( statement( @@ -357,16 +355,14 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { methodName, "PromptSpec(), this::", methodName, - "))", - semicolon(kt), - "\n")); + "));")); } } else if (route.isMcpResource() || route.isMcpResourceTemplate()) { - boolean isTemplate = route.isMcpResourceTemplate(); - String specType = + var isTemplate = route.isMcpResourceTemplate(); + var specType = isTemplate ? "SyncResourceTemplateSpecification" : "SyncResourceSpecification"; - String addMethod = isTemplate ? "server.addResourceTemplate(" : "server.addResource("; - String defMethod = isTemplate ? "ResourceTemplateSpec()" : "ResourceSpec()"; + var addMethod = isTemplate ? "server.addResourceTemplate(" : "server.addResource("; + var defMethod = isTemplate ? "ResourceTemplateSpec()" : "ResourceSpec()"; if (kt) { buffer.append( @@ -380,7 +376,7 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { defMethod, ", this::", methodName, - "))\n")); + "))")); } else { buffer.append( statement( @@ -393,31 +389,21 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { defMethod, ", this::", methodName, - "))", - semicolon(kt), - "\n")); + "));")); } } } - buffer.append(statement(indent(4), "}\n")); + buffer.append(statement(indent(4), "}", System.lineSeparator())); - for (McpRoute route : - getRoutes().stream() - .filter( - r -> - r.isMcpTool() - || r.isMcpPrompt() - || r.isMcpResource() - || r.isMcpResourceTemplate()) - .toList()) { + for (var route : getRoutes()) { route.generateMcpDefinitionMethod(kt).forEach(buffer::append); route.generateMcpHandlerMethod(kt).forEach(buffer::append); } for (var entry : completionGroups.entrySet()) { - String ref = entry.getKey(); - String handlerName = findTargetMethodName(ref) + "CompletionHandler"; - java.util.List routes = entry.getValue(); + var ref = entry.getKey(); + var handlerName = findTargetMethodName(ref) + "CompletionHandler"; + var routes = entry.getValue(); if (kt) { buffer.append( @@ -464,12 +450,12 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { buffer.append(statement(indent(6), "return switch (targetArg) {")); } - for (McpRoute route : routes) { + for (var route : routes) { String targetArgName = null; - java.util.List invokeArgs = new java.util.ArrayList<>(); + var invokeArgs = new java.util.ArrayList(); - for (var param : route.getParameters(false)) { - String type = param.getType().getRawType().toString(); + for (var param : route.getParameters(true)) { + var type = param.getType().getRawType().toString(); if (type.equals("io.jooby.Context")) { invokeArgs.add("ctx"); } else if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange")) { @@ -530,7 +516,7 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { semicolon(kt))); buffer.append(statement(indent(6), "}", semicolon(kt))); } - buffer.append(statement(indent(4), "}\n")); + buffer.append(statement(indent(4), "}", System.lineSeparator())); } return template diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java index f0bc9207af..902af5abad 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java @@ -187,15 +187,14 @@ public List generateHandlerCall(boolean kt) { String projection = getProjection(); - // Bulletproof check: Is the controller natively returning a Projected type? boolean isProjectedReturnType = customReturnType.isProjection() || customReturnType.is(Types.PROJECTED); - // 1. Create separate variables for the generated HTTP handler's signature + // Create separate variables for the generated HTTP handler's signature var handlerTypeGenerics = returnTypeGenerics; var handlerTypeString = returnTypeString; - // 2. ONLY modify the signature if we need to wrap a NON-projected type + // ONLY modify the signature if we need to wrap a NON-projected type if (projection != null && !isProjectedReturnType) { handlerTypeGenerics = ""; handlerTypeString = Types.PROJECTED + "<" + returnTypeString + ">"; @@ -212,7 +211,7 @@ public List generateHandlerCall(boolean kt) { int controllerIndent = 2; for (var parameter : getParameters(true)) { - String generatedParameter = parameter.generateMapping(kt); + var generatedParameter = parameter.generateMapping(kt); if (parameter.isRequireBeanValidation()) { generatedParameter = CodeBlock.of( diff --git a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java index cdf3da22d6..3c9ae19bb4 100644 --- a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java +++ b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java @@ -17,6 +17,7 @@ public void shouldGenerateMcpServer() throws Exception { new ProcessorRunner(new ExampleServer()) .withMcpCode( source -> { + System.out.println(source); assertThat(source) .isEqualToNormalizingWhitespace( """ From dcc65d377c2453a9637cfb2577cad47822d34f77 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Thu, 26 Mar 2026 14:11:32 -0300 Subject: [PATCH 12/37] mcp: bind response to mcp responses --- .../java/io/jooby/internal/apt/McpRoute.java | 77 ++++++--- .../src/test/java/tests/i3830/Issue3830.java | 7 +- .../src/main/java/io/jooby/mcp/McpResult.java | 156 +++++++++++++++++- 3 files changed, 207 insertions(+), 33 deletions(-) diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java index 5ea1f122e5..c14c33145e 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java @@ -169,24 +169,7 @@ public List generateMcpDefinitionMethod(boolean kt) { } String returnTypeStr = getReturnType().getRawType().toString(); - boolean isPrimitive = - returnTypeStr.equals("int") - || returnTypeStr.equals("long") - || returnTypeStr.equals("double") - || returnTypeStr.equals("float") - || returnTypeStr.equals("boolean") - || returnTypeStr.equals("byte") - || returnTypeStr.equals("short") - || returnTypeStr.equals("char"); - boolean isLangClass = returnTypeStr.startsWith("java.lang."); - boolean isMcpClass = returnTypeStr.startsWith("io.modelcontextprotocol.spec.McpSchema"); - - boolean generateOutputSchema = - !returnType.isVoid() - && !getReturnType().is("io.jooby.StatusCode") - && !isPrimitive - && !isLangClass - && !isMcpClass; + boolean generateOutputSchema = hasOutputSchema(); String outputSchemaArg = "null"; if (generateOutputSchema) { @@ -644,18 +627,36 @@ public List generateMcpHandlerMethod(boolean kt) { var methodCall = "c." + getMethodName() + "(" + String.join(", ", javaParamNames) + ")"; + // Prefix for Resources: "req.uri(), " + String toMethodPrefix = (isMcpResource() || isMcpResourceTemplate()) ? "req.uri(), " : ""; + + // Suffix for Tools: ", true" or ", false" + String toMethodSuffix = isMcpTool() ? ", " + hasOutputSchema() : ""; + if (getReturnType().isVoid()) { buffer.add(statement(indent(6), methodCall, semicolon(kt))); if (kt) { buffer.add( - statement(indent(6), "return io.jooby.mcp.McpResult(this.json).", toMethod, "(null)")); + statement( + indent(6), + "return io.jooby.mcp.McpResult(this.json).", + toMethod, + "(", + toMethodPrefix, + "null", + toMethodSuffix, + ")")); } else { buffer.add( statement( indent(6), "return new io.jooby.mcp.McpResult(this.json).", toMethod, - "(null)", + "(", + toMethodPrefix, + "null", + toMethodSuffix, + ")", semicolon(kt))); } } else { @@ -663,7 +664,14 @@ public List generateMcpHandlerMethod(boolean kt) { buffer.add(statement(indent(6), "val result = ", methodCall)); buffer.add( statement( - indent(6), "return io.jooby.mcp.McpResult(this.json).", toMethod, "(result)")); + indent(6), + "return io.jooby.mcp.McpResult(this.json).", + toMethod, + "(", + toMethodPrefix, + "result", + toMethodSuffix, + ")")); } else { buffer.add(statement(indent(6), "var result = ", methodCall, semicolon(kt))); buffer.add( @@ -671,7 +679,11 @@ public List generateMcpHandlerMethod(boolean kt) { indent(6), "return new io.jooby.mcp.McpResult(this.json).", toMethod, - "(result)", + "(", + toMethodPrefix, + "result", + toMethodSuffix, + ")", semicolon(kt))); } } @@ -679,4 +691,25 @@ public List generateMcpHandlerMethod(boolean kt) { return buffer; } + + private boolean hasOutputSchema() { + var returnTypeStr = getReturnType().getRawType().toString(); + var isPrimitive = + returnTypeStr.equals("int") + || returnTypeStr.equals("long") + || returnTypeStr.equals("double") + || returnTypeStr.equals("float") + || returnTypeStr.equals("boolean") + || returnTypeStr.equals("byte") + || returnTypeStr.equals("short") + || returnTypeStr.equals("char"); + var isLangClass = returnTypeStr.startsWith("java.lang."); + var isMcpClass = returnTypeStr.startsWith("io.modelcontextprotocol.spec.McpSchema"); + + return !getReturnType().isVoid() + && !getReturnType().is("io.jooby.StatusCode") + && !isPrimitive + && !isLangClass + && !isMcpClass; + } } diff --git a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java index 3c9ae19bb4..472eeb6902 100644 --- a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java +++ b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java @@ -17,7 +17,6 @@ public void shouldGenerateMcpServer() throws Exception { new ProcessorRunner(new ExampleServer()) .withMcpCode( source -> { - System.out.println(source); assertThat(source) .isEqualToNormalizingWhitespace( """ @@ -108,7 +107,7 @@ private io.modelcontextprotocol.spec.McpSchema.CallToolResult add(io.modelcontex if (raw_b == null) throw new IllegalArgumentException("Missing req param: b"); var b = raw_b instanceof Number ? ((Number) raw_b).intValue() : Integer.parseInt(raw_b.toString()); var result = c.add(a, b); - return new io.jooby.mcp.McpResult(this.json).toCallToolResult(result); + return new io.jooby.mcp.McpResult(this.json).toCallToolResult(result, false); } private io.modelcontextprotocol.spec.McpSchema.Prompt reviewCodePromptSpec() { @@ -139,7 +138,7 @@ private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getLogs(io.mod var args = java.util.Collections.emptyMap(); var c = this.factory.apply(ctx); var result = c.getLogs(); - return new io.jooby.mcp.McpResult(this.json).toResourceResult(result); + return new io.jooby.mcp.McpResult(this.json).toResourceResult(req.uri(), result); } private io.modelcontextprotocol.spec.McpSchema.ResourceTemplate getUserProfileResourceTemplateSpec() { @@ -156,7 +155,7 @@ private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getUserProfile var raw_id = args.get("id"); var id = raw_id != null ? raw_id.toString() : null; var result = c.getUserProfile(id); - return new io.jooby.mcp.McpResult(this.json).toResourceResult(result); + return new io.jooby.mcp.McpResult(this.json).toResourceResult(req.uri(), result); } private io.modelcontextprotocol.spec.McpSchema.CompleteResult getUserProfileCompletionHandler(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) { diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpResult.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpResult.java index 36d194b74e..41cbc7b492 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpResult.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpResult.java @@ -5,26 +5,168 @@ */ package io.jooby.mcp; +import static io.modelcontextprotocol.spec.McpSchema.ErrorCodes.INTERNAL_ERROR; +import static io.modelcontextprotocol.spec.McpSchema.Role.USER; + +import java.io.IOException; +import java.util.List; +import java.util.Objects; + +import io.jooby.SneakyThrows; import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; public class McpResult { - public McpResult(McpJsonMapper json) {} + private final McpJsonMapper json; + + public McpResult(McpJsonMapper json) { + this.json = json; + } - public McpSchema.CallToolResult toCallToolResult(Object result) { - return null; + public McpSchema.CallToolResult toCallToolResult(Object result, boolean structuredContent) { + try { + if (result == null) { + return buildTextResult("null", false); + } else if (result instanceof McpSchema.CallToolResult callToolResult) { + return callToolResult; + } else if (result instanceof String str) { + return buildTextResult(str, false); + } else if (result instanceof McpSchema.Content content) { + return McpSchema.CallToolResult.builder().content(List.of(content)).isError(false).build(); + } else { + if (structuredContent) { + return McpSchema.CallToolResult.builder() + .structuredContent(result) + .isError(false) + .build(); + } else { + return buildTextResult(json.writeValueAsString(result), false); + } + } + } catch (Exception x) { + throw SneakyThrows.propagate(x); + } } public McpSchema.GetPromptResult toPromptResult(Object result) { - return null; + if (result == null) { + return new McpSchema.GetPromptResult(null, List.of()); + } else if (result instanceof McpSchema.GetPromptResult promptResult) { + return promptResult; + } else if (result instanceof McpSchema.PromptMessage promptMessage) { + return new McpSchema.GetPromptResult(null, List.of(promptMessage)); + } else if (result instanceof McpSchema.Content content) { + var promptMessage = new McpSchema.PromptMessage(USER, content); + return new McpSchema.GetPromptResult(null, List.of(promptMessage)); + } else if (result instanceof String str) { + var promptMessage = new McpSchema.PromptMessage(USER, new McpSchema.TextContent(str)); + return new McpSchema.GetPromptResult(null, List.of(promptMessage)); + } else if (result instanceof List items) { + //noinspection unchecked + return handleListReturnType((List) result, items); + } else { + var promptMessage = + new McpSchema.PromptMessage(USER, new McpSchema.TextContent(result.toString())); + return new McpSchema.GetPromptResult(null, List.of(promptMessage)); + } } - public McpSchema.ReadResourceResult toResourceResult(Object result) { - return null; + public McpSchema.ReadResourceResult toResourceResult(String uri, Object result) { + try { + if (result == null) { + return new McpSchema.ReadResourceResult(List.of()); + } else if (result instanceof McpSchema.ReadResourceResult resourceResult) { + return resourceResult; + } else if (result instanceof McpSchema.ResourceContents resourceContents) { + return new McpSchema.ReadResourceResult(List.of(resourceContents)); + } else if (result instanceof List contents) { + return handleListReturnType(result, uri, json, contents); + } else { + return toJsonResult(result, uri, json); + } + } catch (Exception x) { + throw SneakyThrows.propagate(x); + } } public McpSchema.CompleteResult toCompleteResult(Object result) { - return null; + try { + Objects.requireNonNull(result, "Completion result cannot be null"); + + if (result instanceof McpSchema.CompleteResult completeResult) { + return completeResult; + } else if (result instanceof McpSchema.CompleteResult.CompleteCompletion completion) { + return new McpSchema.CompleteResult(completion); + } else if (result instanceof List values) { + if (values.isEmpty()) { + return new McpSchema.CompleteResult( + new McpSchema.CompleteResult.CompleteCompletion(List.of(), 0, false)); + } else { + var item = values.getFirst(); + if (item instanceof String) { + //noinspection unchecked + return new McpSchema.CompleteResult( + new McpSchema.CompleteResult.CompleteCompletion( + (List) values, values.size(), false)); + } + } + } else if (result instanceof String singleValue) { + var completion = + new McpSchema.CompleteResult.CompleteCompletion(List.of(singleValue), 1, false); + return new McpSchema.CompleteResult(completion); + } + + throw new IllegalStateException("Unexpected error occurred while handling completion result"); + } catch (Exception ex) { + throw new McpError( + new McpSchema.JSONRPCResponse.JSONRPCError(INTERNAL_ERROR, ex.getMessage(), null)); + } + } + + private static McpSchema.ReadResourceResult handleListReturnType( + Object result, String uri, McpJsonMapper mcpJsonMapper, List contents) throws IOException { + if (contents.isEmpty()) { + return new McpSchema.ReadResourceResult(List.of()); + } else { + var item = contents.getFirst(); + if (item instanceof McpSchema.ResourceContents) { + //noinspection unchecked + return new McpSchema.ReadResourceResult((List) contents); + } else { + return toJsonResult(result, uri, mcpJsonMapper); + } + } + } + + static McpSchema.ReadResourceResult toJsonResult( + Object result, String uri, McpJsonMapper mcpJsonMapper) throws IOException { + var resultStr = mcpJsonMapper.writeValueAsString(result); + var content = new McpSchema.TextResourceContents(uri, "application/json", resultStr); + return new McpSchema.ReadResourceResult(List.of(content)); + } + + private McpSchema.CallToolResult buildTextResult(String text, boolean isError) { + return McpSchema.CallToolResult.builder().addTextContent(text).isError(isError).build(); + } + + private static McpSchema.GetPromptResult handleListReturnType( + List result, List items) { + if (items.isEmpty()) { + return new McpSchema.GetPromptResult(null, List.of()); + } else { + var item = items.getFirst(); + if (item instanceof McpSchema.PromptMessage) { + return new McpSchema.GetPromptResult(null, result); + } else { + var msgs = + items.stream() + .map( + i -> new McpSchema.PromptMessage(USER, new McpSchema.TextContent(i.toString()))) + .toList(); + return new McpSchema.GetPromptResult(null, msgs); + } + } } } From 9532561e520996ca5a92aeeb8d160ac22fb30960 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Fri, 27 Mar 2026 13:11:48 -0300 Subject: [PATCH 13/37] fix(mcp): register completions capability and unify generated handlers This commit fixes an issue where `@McpCompletion` requests were failing with a "-32601 Missing handler" error because the `completions()` capability was omitted from the generated `ServerCapabilities`. It also introduces a major refactor to the APT generation to eliminate duplicate code. Details: * Added `capabilities.completions()` to the generated `capabilities()` method. * Safely stripped lingering AST quotes from `@McpCompletion` annotation values to ensure precise router matching. * Refactored `McpRoute` to generate a single, unified handler method accepting both `McpSyncServerExchange` and `McpTransportContext` alongside the request, dynamically extracting `io.jooby.Context` from the available transport. * Updated `McpRouter` to register tools, prompts, resources, and completions using clean lambda adapters that map the current server type (stateful vs. stateless) to the unified handler. --- .../java/io/jooby/internal/apt/McpRoute.java | 38 +- .../java/io/jooby/internal/apt/McpRouter.java | 351 ++++++++++++------ .../java/tests/i3830/ExampleServerMcp_.java | 307 +++++++++++++++ .../src/test/java/tests/i3830/Issue3830.java | 348 +++++++++-------- modules/jooby-mcp/pom.xml | 7 +- .../jooby/internal/mcp/McpServerConfig.java | 40 +- .../internal/mcp/McpSyncServerRunner.java | 7 +- .../src/main/java/io/jooby/mcp/McpModule.java | 172 +++++++-- .../main/java/io/jooby/mcp/McpService.java | 9 +- .../transport/JoobySseTransportProvider.java | 19 +- ...oobyStreamableServerTransportProvider.java | 7 +- tests/pom.xml | 5 + .../java/io/jooby/i3830/CalculatorTools.java | 55 +++ .../io/jooby/i3830/CalculatorToolsTest.java | 209 +++++++++++ .../src/test/java/io/jooby/i3830/McpTest.java | 8 + 15 files changed, 1226 insertions(+), 356 deletions(-) create mode 100644 modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java create mode 100644 tests/src/test/java/io/jooby/i3830/CalculatorTools.java create mode 100644 tests/src/test/java/io/jooby/i3830/CalculatorToolsTest.java create mode 100644 tests/src/test/java/io/jooby/i3830/McpTest.java diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java index c14c33145e..a3e8769316 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java @@ -138,6 +138,7 @@ public List generateMcpDefinitionMethod(boolean kt) { for (var param : getParameters(true)) { var type = param.getType().getRawType().toString(); if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange") + || type.equals("io.modelcontextprotocol.common.McpTransportContext") || type.equals("io.jooby.Context")) continue; String mcpName = param.getMcpName(); @@ -276,6 +277,7 @@ public List generateMcpDefinitionMethod(boolean kt) { for (var param : getParameters(true)) { var type = param.getType().getRawType().toString(); if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange") + || type.equals("io.modelcontextprotocol.common.McpTransportContext") || type.equals("io.jooby.Context")) continue; var mcpName = param.getMcpName(); @@ -431,14 +433,16 @@ public List generateMcpHandlerMethod(boolean kt) { } List buffer = new ArrayList<>(); + String handlerName = getMethodName(); if (kt) { buffer.add( statement( indent(4), "private fun ", - getMethodName(), - "(exchange: io.modelcontextprotocol.server.McpSyncServerExchange, req:" + handlerName, + "(exchange: io.modelcontextprotocol.server.McpSyncServerExchange?, transportContext:" + + " io.modelcontextprotocol.common.McpTransportContext?, req:" + " io.modelcontextprotocol.spec.McpSchema.", reqType, "): io.modelcontextprotocol.spec.McpSchema.", @@ -448,7 +452,8 @@ public List generateMcpHandlerMethod(boolean kt) { statement( indent(6), "val ctx =" - + " exchange.transportContext().get(io.jooby.Context::class.java.name)")); + + " exchange?.transportContext()?.get(io.jooby.Context::class.java.name)" + + " ?: transportContext?.get(io.jooby.Context::class.java.name)")); } else { buffer.add( statement( @@ -456,15 +461,18 @@ public List generateMcpHandlerMethod(boolean kt) { "private io.modelcontextprotocol.spec.McpSchema.", resType, " ", - getMethodName(), + handlerName, "(io.modelcontextprotocol.server.McpSyncServerExchange exchange," + + " io.modelcontextprotocol.common.McpTransportContext transportContext," + " io.modelcontextprotocol.spec.McpSchema.", reqType, " req) {")); buffer.add( statement( indent(6), - "var ctx = (io.jooby.Context) exchange.transportContext().get(\"CTX\")", + "var ctx = exchange != null ? (io.jooby.Context)" + + " exchange.transportContext().get(\"CTX\") : (transportContext != null ?" + + " (io.jooby.Context) transportContext.get(\"CTX\") : null)", semicolon(kt))); } @@ -541,6 +549,24 @@ public List generateMcpHandlerMethod(boolean kt) { buffer.add( statement(indent(6), kt ? "val " : "var ", javaName, " = exchange", semicolon(kt))); continue; + } else if (type.equals("io.modelcontextprotocol.common.McpTransportContext")) { + if (kt) { + buffer.add( + statement( + indent(6), + "val ", + javaName, + " = exchange?.transportContext() ?: transportContext")); + } else { + buffer.add( + statement( + indent(6), + "var ", + javaName, + " = exchange != null ? exchange.transportContext() : transportContext", + semicolon(kt))); + } + continue; } else if (type.equals("io.modelcontextprotocol.spec.McpSchema." + reqType)) { buffer.add(statement(indent(6), kt ? "val " : "var ", javaName, " = req", semicolon(kt))); continue; @@ -687,7 +713,7 @@ public List generateMcpHandlerMethod(boolean kt) { semicolon(kt))); } } - buffer.add(statement(indent(4), "}\n")); + buffer.add(statement(indent(4), "}", System.lineSeparator())); return buffer; } diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java index 407c86645b..3bbd37638d 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java @@ -120,7 +120,6 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { var completionRoutes = getRoutes().stream().filter(McpRoute::isMcpCompletion).toList(); var completionGroups = new java.util.LinkedHashMap>(); - // group completion we need to genereate a single handler for all completion routes for (var route : completionRoutes) { var annotation = AnnotationSupport.findAnnotationByName( @@ -148,6 +147,7 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { indent(4), "private io.modelcontextprotocol.json.McpJsonMapper json", semicolon(kt))); } + // --- capabilities() --- if (kt) { buffer.append( statement( @@ -169,8 +169,13 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { buffer.append(statement(indent(6), "capabilities.prompts(true)", semicolon(kt))); if (!resources.isEmpty()) buffer.append(statement(indent(6), "capabilities.resources(true, true)", semicolon(kt))); + // ADD THIS BLOCK: + if (!completionGroups.isEmpty()) { + buffer.append(statement(indent(6), "capabilities.completions()", semicolon(kt))); + } buffer.append(statement(indent(4), "}\n")); + // --- serverName() --- var serverName = getMcpServerKey(); if (kt) { buffer.append(statement(indent(4), "override fun serverName(): String? {")); @@ -182,6 +187,7 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { } buffer.append(statement(indent(4), "}\n")); + // --- completions() --- if (kt) { buffer.append( statement( @@ -218,6 +224,11 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { ? "io.modelcontextprotocol.spec.McpSchema.ResourceReference" : "io.modelcontextprotocol.spec.McpSchema.PromptReference"; + String lambda = + kt + ? "{ exchange, req -> this." + handlerName + "(exchange, null, req) }" + : "(exchange, req) -> this." + handlerName + "(exchange, null, req)"; + if (kt) { buffer.append( statement( @@ -226,8 +237,8 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { refObj, "(", string(ref), - "), this::", - handlerName, + "), ", + lambda, "))")); } else { buffer.append( @@ -239,8 +250,8 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { refObj, "(", string(ref), - "), this::", - handlerName, + "), ", + lambda, "))", semicolon(kt))); } @@ -248,158 +259,269 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { buffer.append(statement(indent(6), "return completions", semicolon(kt))); buffer.append(statement(indent(4), "}\n")); + // --- statelessCompletions() --- if (kt) { - buffer.append(statement(indent(4), "@Throws(Exception::class)")); buffer.append( statement( indent(4), - "override fun install(app: io.jooby.Jooby, server:" - + " io.modelcontextprotocol.server.McpSyncServer, json:" - + " io.modelcontextprotocol.json.McpJsonMapper) {")); - buffer.append(statement(indent(6), "this.json = json")); + "override fun statelessCompletions():" + + " List" + + " {")); buffer.append( statement( indent(6), - "val mapper = app.require(tools.jackson.databind.ObjectMapper::class.java)")); - if (!tools.isEmpty()) { - buffer.append( - statement( - indent(6), - "val configBuilder =" - + " com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder(com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12," - + " com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON)")); - buffer.append( - statement( - indent(6), - "val schemaGenerator =" - + " com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build())")); - } + "val completions =" + + " mutableListOf()")); } else { buffer.append(statement(indent(4), "@Override")); buffer.append( statement( indent(4), - "public void install(io.jooby.Jooby app, io.modelcontextprotocol.server.McpSyncServer" - + " server, io.modelcontextprotocol.json.McpJsonMapper json) throws Exception" - + " {")); - buffer.append(statement(indent(6), "this.json = json", semicolon(kt))); + "public" + + " java.util.List" + + " statelessCompletions() {")); buffer.append( statement( indent(6), - "var mapper = app.require(tools.jackson.databind.ObjectMapper.class)", + "var completions = new" + + " java.util.ArrayList()", semicolon(kt))); - if (!tools.isEmpty()) { + } + + for (var ref : completionGroups.keySet()) { + var isResource = ref.contains("://"); + var handlerName = findTargetMethodName(ref) + "CompletionHandler"; + var refObj = + isResource + ? "io.modelcontextprotocol.spec.McpSchema.ResourceReference" + : "io.modelcontextprotocol.spec.McpSchema.PromptReference"; + + String lambda = + kt + ? "{ ctx, req -> this." + handlerName + "(null, ctx, req) }" + : "(ctx, req) -> this." + handlerName + "(null, ctx, req)"; + + if (kt) { buffer.append( statement( indent(6), - "var configBuilder = new" - + " com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder(com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12," - + " com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON)", - semicolon(kt))); + "completions.add(io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification(", + refObj, + "(", + string(ref), + "), ", + lambda, + "))")); + } else { buffer.append( statement( indent(6), - "var schemaGenerator = new" - + " com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build())", + "completions.add(new" + + " io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification(new" + + " ", + refObj, + "(", + string(ref), + "), ", + lambda, + "))", semicolon(kt))); } } + buffer.append(statement(indent(6), "return completions", semicolon(kt))); + buffer.append(statement(indent(4), "}\n")); - buffer.append(System.lineSeparator()); - - for (var route : getRoutes()) { - var methodName = route.getMethodName(); + // --- install() methods --- + String[] serverTypes = { + "io.modelcontextprotocol.server.McpSyncServer", + "io.modelcontextprotocol.server.McpStatelessSyncServer" + }; - if (route.isMcpTool()) { - var defArgs = "mapper, schemaGenerator"; - if (kt) { + for (String serverType : serverTypes) { + if (kt) { + buffer.append(statement(indent(4), "@Throws(Exception::class)")); + buffer.append( + statement( + indent(4), + "override fun install(app: io.jooby.Jooby, server: " + serverType + ") {")); + buffer.append( + statement( + indent(6), + "this.json =" + + " app.services.require(io.modelcontextprotocol.json.McpJsonMapper::class.java)")); + buffer.append( + statement( + indent(6), + "val mapper = app.require(tools.jackson.databind.ObjectMapper::class.java)")); + if (!tools.isEmpty()) { buffer.append( statement( indent(6), - "server.addTool(io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification(", - methodName, - "ToolSpec(", - defArgs, - "), this::", - methodName, - "))")); - } else { + "val configBuilder =" + + " com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder(com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12," + + " com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON)")); buffer.append( statement( indent(6), - "server.addTool(new" - + " io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification(", - methodName, - "ToolSpec(", - defArgs, - "), this::", - methodName, - "));")); + "val schemaGenerator =" + + " com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build())")); } - } else if (route.isMcpPrompt()) { - if (kt) { + } else { + buffer.append(statement(indent(4), "@Override")); + buffer.append( + statement( + indent(4), + "public void install(io.jooby.Jooby app, " + + serverType + + " server) throws Exception {")); + buffer.append( + statement( + indent(6), + "this.json =" + + " app.getServices().require(io.modelcontextprotocol.json.McpJsonMapper.class)", + semicolon(kt))); + buffer.append( + statement( + indent(6), + "var mapper = app.require(tools.jackson.databind.ObjectMapper.class)", + semicolon(kt))); + if (!tools.isEmpty()) { buffer.append( statement( indent(6), - "server.addPrompt(io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification(", - methodName, - "PromptSpec(), this::", - methodName, - "))")); - } else { + "var configBuilder = new" + + " com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder(com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12," + + " com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON)", + semicolon(kt))); buffer.append( statement( indent(6), - "server.addPrompt(new" - + " io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification(", - methodName, - "PromptSpec(), this::", - methodName, - "));")); + "var schemaGenerator = new" + + " com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build())", + semicolon(kt))); } - } else if (route.isMcpResource() || route.isMcpResourceTemplate()) { - var isTemplate = route.isMcpResourceTemplate(); - var specType = - isTemplate ? "SyncResourceTemplateSpecification" : "SyncResourceSpecification"; - var addMethod = isTemplate ? "server.addResourceTemplate(" : "server.addResource("; - var defMethod = isTemplate ? "ResourceTemplateSpec()" : "ResourceSpec()"; + } - if (kt) { - buffer.append( - statement( - indent(6), - addMethod, - "io.modelcontextprotocol.server.McpServerFeatures.", - specType, - "(", - methodName, - defMethod, - ", this::", - methodName, - "))")); - } else { - buffer.append( - statement( - indent(6), - addMethod, - "new io.modelcontextprotocol.server.McpServerFeatures.", - specType, - "(", - methodName, - defMethod, - ", this::", - methodName, - "));")); + buffer.append(System.lineSeparator()); + + boolean isStateless = serverType.contains("Stateless"); + String featuresClass = isStateless ? "McpStatelessServerFeatures" : "McpServerFeatures"; + + for (var route : getRoutes()) { + var methodName = route.getMethodName(); + + // --- Lambda Router Definition --- + String lambda = + kt + ? (isStateless + ? "{ ctx, req -> this." + methodName + "(null, ctx, req) }" + : "{ exchange, req -> this." + methodName + "(exchange, null, req) }") + : (isStateless + ? "(ctx, req) -> this." + methodName + "(null, ctx, req)" + : "(exchange, req) -> this." + methodName + "(exchange, null, req)"); + + if (route.isMcpTool()) { + var defArgs = "mapper, schemaGenerator"; + if (kt) { + buffer.append( + statement( + indent(6), + "server.addTool(io.modelcontextprotocol.server.", + featuresClass, + ".SyncToolSpecification(", + methodName, + "ToolSpec(", + defArgs, + "), ", + lambda, + "))")); + } else { + buffer.append( + statement( + indent(6), + "server.addTool(new io.modelcontextprotocol.server.", + featuresClass, + ".SyncToolSpecification(", + methodName, + "ToolSpec(", + defArgs, + "), ", + lambda, + "));")); + } + } else if (route.isMcpPrompt()) { + if (kt) { + buffer.append( + statement( + indent(6), + "server.addPrompt(io.modelcontextprotocol.server.", + featuresClass, + ".SyncPromptSpecification(", + methodName, + "PromptSpec(), ", + lambda, + "))")); + } else { + buffer.append( + statement( + indent(6), + "server.addPrompt(new io.modelcontextprotocol.server.", + featuresClass, + ".SyncPromptSpecification(", + methodName, + "PromptSpec(), ", + lambda, + "));")); + } + } else if (route.isMcpResource() || route.isMcpResourceTemplate()) { + var isTemplate = route.isMcpResourceTemplate(); + var specType = + isTemplate ? "SyncResourceTemplateSpecification" : "SyncResourceSpecification"; + var addMethod = isTemplate ? "server.addResourceTemplate(" : "server.addResource("; + var defMethod = isTemplate ? "ResourceTemplateSpec()" : "ResourceSpec()"; + + if (kt) { + buffer.append( + statement( + indent(6), + addMethod, + "io.modelcontextprotocol.server.", + featuresClass, + ".", + specType, + "(", + methodName, + defMethod, + ", ", + lambda, + "))")); + } else { + buffer.append( + statement( + indent(6), + addMethod, + "new io.modelcontextprotocol.server.", + featuresClass, + ".", + specType, + "(", + methodName, + defMethod, + ", ", + lambda, + "));")); + } } } + buffer.append(statement(indent(4), "}", System.lineSeparator())); } - buffer.append(statement(indent(4), "}", System.lineSeparator())); for (var route : getRoutes()) { route.generateMcpDefinitionMethod(kt).forEach(buffer::append); route.generateMcpHandlerMethod(kt).forEach(buffer::append); } + // --- Generate Unified Completion Handlers --- for (var entry : completionGroups.entrySet()) { var ref = entry.getKey(); var handlerName = findTargetMethodName(ref) + "CompletionHandler"; @@ -411,14 +533,16 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { indent(4), "private fun ", handlerName, - "(exchange: io.modelcontextprotocol.server.McpSyncServerExchange, req:" + "(exchange: io.modelcontextprotocol.server.McpSyncServerExchange?," + + " transportContext: io.modelcontextprotocol.common.McpTransportContext?, req:" + " io.modelcontextprotocol.spec.McpSchema.CompleteRequest):" + " io.modelcontextprotocol.spec.McpSchema.CompleteResult {")); buffer.append( statement( indent(6), "val ctx =" - + " exchange.transportContext().get(io.jooby.Context::class.java.name)")); + + " exchange?.transportContext()?.get(io.jooby.Context::class.java.name)" + + " ?: transportContext?.get(io.jooby.Context::class.java.name)")); buffer.append(statement(indent(6), "val c = this.factory.apply(ctx)")); buffer.append(statement(indent(6), "val targetArg = req.argument()?.name() ?: \"\"")); buffer.append(statement(indent(6), "val typedValue = req.argument()?.value() ?: \"\"")); @@ -430,11 +554,14 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { "private io.modelcontextprotocol.spec.McpSchema.CompleteResult ", handlerName, "(io.modelcontextprotocol.server.McpSyncServerExchange exchange," + + " io.modelcontextprotocol.common.McpTransportContext transportContext," + " io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) {")); buffer.append( statement( indent(6), - "var ctx = (io.jooby.Context) exchange.transportContext().get(\"CTX\")", + "var ctx = exchange != null ? (io.jooby.Context)" + + " exchange.transportContext().get(\"CTX\") : (transportContext != null ?" + + " (io.jooby.Context) transportContext.get(\"CTX\") : null)", semicolon(kt))); buffer.append(statement(indent(6), "var c = this.factory.apply(ctx)", semicolon(kt))); buffer.append( @@ -460,6 +587,12 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { invokeArgs.add("ctx"); } else if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange")) { invokeArgs.add("exchange"); + } else if (type.equals("io.modelcontextprotocol.common.McpTransportContext")) { + if (kt) { + invokeArgs.add("exchange?.transportContext() ?: transportContext"); + } else { + invokeArgs.add("exchange != null ? exchange.transportContext() : transportContext"); + } } else { targetArgName = param.getMcpName(); invokeArgs.add("typedValue"); diff --git a/modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java new file mode 100644 index 0000000000..8c12056f62 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java @@ -0,0 +1,307 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.i3830; + +@io.jooby.annotation.Generated(ExampleServer.class) +public class ExampleServerMcp_ implements io.jooby.mcp.McpService { + protected java.util.function.Function factory; + + public ExampleServerMcp_() { + this(io.jooby.SneakyThrows.singleton(ExampleServer::new)); + } + + public ExampleServerMcp_(ExampleServer instance) { + setup(ctx -> instance); + } + + public ExampleServerMcp_(io.jooby.SneakyThrows.Supplier provider) { + setup(ctx -> (ExampleServer) provider.get()); + } + + public ExampleServerMcp_( + io.jooby.SneakyThrows.Function, ExampleServer> provider) { + setup(ctx -> provider.apply(ExampleServer.class)); + } + + private void setup(java.util.function.Function factory) { + this.factory = factory; + } + + private io.modelcontextprotocol.json.McpJsonMapper json; + + @Override + public void capabilities( + io.modelcontextprotocol.spec.McpSchema.ServerCapabilities.Builder capabilities) { + capabilities.tools(true); + capabilities.prompts(true); + capabilities.resources(true, true); + } + + @Override + public String serverName() { + return "example-server"; + } + + @Override + public java.util.List< + io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification> + completions() { + var completions = + new java.util.ArrayList< + io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification>(); + completions.add( + new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification( + new io.modelcontextprotocol.spec.McpSchema.ResourceReference( + "file:///users/{id}/{name}/profile"), + (exchange, req) -> this.getUserProfileCompletionHandler(exchange, null, req))); + completions.add( + new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification( + new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), + (exchange, req) -> this.reviewCodeCompletionHandler(exchange, null, req))); + return completions; + } + + @Override + public java.util.List< + io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification> + statelessCompletions() { + var completions = + new java.util.ArrayList< + io.modelcontextprotocol.server.McpStatelessServerFeatures + .SyncCompletionSpecification>(); + completions.add( + new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification( + new io.modelcontextprotocol.spec.McpSchema.ResourceReference( + "file:///users/{id}/{name}/profile"), + (ctx, req) -> this.getUserProfileCompletionHandler(null, ctx, req))); + completions.add( + new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification( + new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), + (ctx, req) -> this.reviewCodeCompletionHandler(null, ctx, req))); + return completions; + } + + @Override + public void install(io.jooby.Jooby app, io.modelcontextprotocol.server.McpSyncServer server) + throws Exception { + this.json = app.getServices().require(io.modelcontextprotocol.json.McpJsonMapper.class); + var mapper = app.require(tools.jackson.databind.ObjectMapper.class); + var configBuilder = + new com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder( + com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12, + com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON); + var schemaGenerator = + new com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build()); + + server.addTool( + new io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification( + addToolSpec(mapper, schemaGenerator), + (exchange, req) -> this.add(exchange, null, req))); + server.addPrompt( + new io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification( + reviewCodePromptSpec(), (exchange, req) -> this.reviewCode(exchange, null, req))); + server.addResource( + new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification( + getLogsResourceSpec(), (exchange, req) -> this.getLogs(exchange, null, req))); + server.addResourceTemplate( + new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceTemplateSpecification( + getUserProfileResourceTemplateSpec(), + (exchange, req) -> this.getUserProfile(exchange, null, req))); + } + + @Override + public void install( + io.jooby.Jooby app, io.modelcontextprotocol.server.McpStatelessSyncServer server) + throws Exception { + this.json = app.getServices().require(io.modelcontextprotocol.json.McpJsonMapper.class); + var mapper = app.require(tools.jackson.databind.ObjectMapper.class); + var configBuilder = + new com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder( + com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12, + com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON); + var schemaGenerator = + new com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build()); + + server.addTool( + new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncToolSpecification( + addToolSpec(mapper, schemaGenerator), (ctx, req) -> this.add(null, ctx, req))); + server.addPrompt( + new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncPromptSpecification( + reviewCodePromptSpec(), (ctx, req) -> this.reviewCode(null, ctx, req))); + server.addResource( + new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceSpecification( + getLogsResourceSpec(), (ctx, req) -> this.getLogs(null, ctx, req))); + server.addResourceTemplate( + new io.modelcontextprotocol.server.McpStatelessServerFeatures + .SyncResourceTemplateSpecification( + getUserProfileResourceTemplateSpec(), + (ctx, req) -> this.getUserProfile(null, ctx, req))); + } + + private io.modelcontextprotocol.spec.McpSchema.Tool addToolSpec( + tools.jackson.databind.ObjectMapper mapper, + com.github.victools.jsonschema.generator.SchemaGenerator schemaGenerator) { + var schema = mapper.createObjectNode(); + schema.put("type", "object"); + var props = schema.putObject("properties"); + var req = schema.putArray("required"); + props.set("a", schemaGenerator.generateSchema(int.class)); + req.add("a"); + props.set("b", schemaGenerator.generateSchema(int.class)); + req.add("b"); + return new io.modelcontextprotocol.spec.McpSchema.Tool( + "calculator", + null, + "A simple calculator", + mapper.treeToValue(schema, io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), + null, + null, + null); + } + + private io.modelcontextprotocol.spec.McpSchema.CallToolResult add( + io.modelcontextprotocol.server.McpSyncServerExchange exchange, + io.modelcontextprotocol.common.McpTransportContext transportContext, + io.modelcontextprotocol.spec.McpSchema.CallToolRequest req) { + var ctx = + exchange != null + ? (io.jooby.Context) exchange.transportContext().get("CTX") + : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var args = + req.arguments() != null + ? req.arguments() + : java.util.Collections.emptyMap(); + var c = this.factory.apply(ctx); + var raw_a = args.get("a"); + if (raw_a == null) throw new IllegalArgumentException("Missing req param: a"); + var a = + raw_a instanceof Number ? ((Number) raw_a).intValue() : Integer.parseInt(raw_a.toString()); + var raw_b = args.get("b"); + if (raw_b == null) throw new IllegalArgumentException("Missing req param: b"); + var b = + raw_b instanceof Number ? ((Number) raw_b).intValue() : Integer.parseInt(raw_b.toString()); + var result = c.add(a, b); + return new io.jooby.mcp.McpResult(this.json).toCallToolResult(result, false); + } + + private io.modelcontextprotocol.spec.McpSchema.Prompt reviewCodePromptSpec() { + var args = new java.util.ArrayList(); + args.add(new io.modelcontextprotocol.spec.McpSchema.PromptArgument("language", null, false)); + args.add(new io.modelcontextprotocol.spec.McpSchema.PromptArgument("code", null, false)); + return new io.modelcontextprotocol.spec.McpSchema.Prompt("review_code", null, "", args); + } + + private io.modelcontextprotocol.spec.McpSchema.GetPromptResult reviewCode( + io.modelcontextprotocol.server.McpSyncServerExchange exchange, + io.modelcontextprotocol.common.McpTransportContext transportContext, + io.modelcontextprotocol.spec.McpSchema.GetPromptRequest req) { + var ctx = + exchange != null + ? (io.jooby.Context) exchange.transportContext().get("CTX") + : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var args = + req.arguments() != null + ? req.arguments() + : java.util.Collections.emptyMap(); + var c = this.factory.apply(ctx); + var raw_language = args.get("language"); + var language = raw_language != null ? raw_language.toString() : null; + var raw_code = args.get("code"); + var code = raw_code != null ? raw_code.toString() : null; + var result = c.reviewCode(language, code); + return new io.jooby.mcp.McpResult(this.json).toPromptResult(result); + } + + private io.modelcontextprotocol.spec.McpSchema.Resource getLogsResourceSpec() { + return new io.modelcontextprotocol.spec.McpSchema.Resource( + "file:///logs/app.log", "getLogs", null, "", null, null, null, null); + } + + private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getLogs( + io.modelcontextprotocol.server.McpSyncServerExchange exchange, + io.modelcontextprotocol.common.McpTransportContext transportContext, + io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest req) { + var ctx = + exchange != null + ? (io.jooby.Context) exchange.transportContext().get("CTX") + : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var args = java.util.Collections.emptyMap(); + var c = this.factory.apply(ctx); + var result = c.getLogs(); + return new io.jooby.mcp.McpResult(this.json).toResourceResult(req.uri(), result); + } + + private io.modelcontextprotocol.spec.McpSchema.ResourceTemplate + getUserProfileResourceTemplateSpec() { + return new io.modelcontextprotocol.spec.McpSchema.ResourceTemplate( + "file:///users/{id}/{name}/profile", "getUserProfile", null, "", null, null, null); + } + + private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getUserProfile( + io.modelcontextprotocol.server.McpSyncServerExchange exchange, + io.modelcontextprotocol.common.McpTransportContext transportContext, + io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest req) { + var ctx = + exchange != null + ? (io.jooby.Context) exchange.transportContext().get("CTX") + : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var uri = req.uri(); + var manager = + new io.modelcontextprotocol.util.DefaultMcpUriTemplateManager( + "file:///users/{id}/{name}/profile"); + var args = new java.util.HashMap(); + args.putAll(manager.extractVariableValues(uri)); + var c = this.factory.apply(ctx); + var raw_id = args.get("id"); + var id = raw_id != null ? raw_id.toString() : null; + var result = c.getUserProfile(id); + return new io.jooby.mcp.McpResult(this.json).toResourceResult(req.uri(), result); + } + + private io.modelcontextprotocol.spec.McpSchema.CompleteResult getUserProfileCompletionHandler( + io.modelcontextprotocol.server.McpSyncServerExchange exchange, + io.modelcontextprotocol.common.McpTransportContext transportContext, + io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) { + var ctx = + exchange != null + ? (io.jooby.Context) exchange.transportContext().get("CTX") + : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var c = this.factory.apply(ctx); + var targetArg = req.argument() != null ? req.argument().name() : ""; + var typedValue = req.argument() != null ? req.argument().value() : ""; + return switch (targetArg) { + case "id" -> { + var result = c.completeUserId(typedValue); + yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); + } + case "name" -> { + var result = c.completeUserName(typedValue); + yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); + } + default -> new io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of()); + }; + } + + private io.modelcontextprotocol.spec.McpSchema.CompleteResult reviewCodeCompletionHandler( + io.modelcontextprotocol.server.McpSyncServerExchange exchange, + io.modelcontextprotocol.common.McpTransportContext transportContext, + io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) { + var ctx = + exchange != null + ? (io.jooby.Context) exchange.transportContext().get("CTX") + : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var c = this.factory.apply(ctx); + var targetArg = req.argument() != null ? req.argument().name() : ""; + var typedValue = req.argument() != null ? req.argument().value() : ""; + return switch (targetArg) { + case "language" -> { + var result = c.reviewCodelanguage(typedValue); + yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); + } + default -> new io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of()); + }; + } +} diff --git a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java index 472eeb6902..22dea2daf5 100644 --- a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java +++ b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java @@ -24,171 +24,189 @@ public void shouldGenerateMcpServer() throws Exception { @io.jooby.annotation.Generated(ExampleServer.class) public class ExampleServerMcp_ implements io.jooby.mcp.McpService { - protected java.util.function.Function factory; - - public ExampleServerMcp_() { - this(io.jooby.SneakyThrows.singleton(ExampleServer::new)); - } - - public ExampleServerMcp_(ExampleServer instance) { - setup(ctx -> instance); - } - - public ExampleServerMcp_(io.jooby.SneakyThrows.Supplier provider) { - setup(ctx -> (ExampleServer) provider.get()); - } - - public ExampleServerMcp_(io.jooby.SneakyThrows.Function, ExampleServer> provider) { - setup(ctx -> provider.apply(ExampleServer.class)); - } - - private void setup(java.util.function.Function factory) { - this.factory = factory; - } - - private io.modelcontextprotocol.json.McpJsonMapper json; - - @Override - public void capabilities(io.modelcontextprotocol.spec.McpSchema.ServerCapabilities.Builder capabilities) { - capabilities.tools(true); - capabilities.prompts(true); - capabilities.resources(true, true); - } - - @Override - public String serverName() { - return "example-server"; - } - - @Override - public java.util.List completions() { - var completions = new java.util.ArrayList(); - completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.ResourceReference("file:///users/{id}/{name}/profile"), this::getUserProfileCompletionHandler)); - completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), this::reviewCodeCompletionHandler)); - return completions; - } - - @Override - public void install(io.jooby.Jooby app, io.modelcontextprotocol.server.McpSyncServer server, io.modelcontextprotocol.json.McpJsonMapper json) throws Exception { - this.json = json; - var mapper = app.require(tools.jackson.databind.ObjectMapper.class); - var configBuilder = new com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder(com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12, com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON); - var schemaGenerator = new com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build()); - server.addTool(new io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification(addToolSpec(mapper, schemaGenerator), this::add)); - - server.addPrompt(new io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification(reviewCodePromptSpec(), this::reviewCode)); - - server.addResource(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification(getLogsResourceSpec(), this::getLogs)); - - server.addResourceTemplate(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceTemplateSpecification(getUserProfileResourceTemplateSpec(), this::getUserProfile)); - - } - - private io.modelcontextprotocol.spec.McpSchema.Tool addToolSpec(tools.jackson.databind.ObjectMapper mapper, com.github.victools.jsonschema.generator.SchemaGenerator schemaGenerator) { - var schema = mapper.createObjectNode(); - schema.put("type", "object"); - var props = schema.putObject("properties"); - var req = schema.putArray("required"); - props.set("a", schemaGenerator.generateSchema(int.class)); - req.add("a"); - props.set("b", schemaGenerator.generateSchema(int.class)); - req.add("b"); - return new io.modelcontextprotocol.spec.McpSchema.Tool("calculator", null, "A simple calculator", mapper.treeToValue(schema, io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), null, null, null); - } - - private io.modelcontextprotocol.spec.McpSchema.CallToolResult add(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.spec.McpSchema.CallToolRequest req) { - var ctx = (io.jooby.Context) exchange.transportContext().get("CTX"); - var args = req.arguments() != null ? req.arguments() : java.util.Collections.emptyMap(); - var c = this.factory.apply(ctx); - var raw_a = args.get("a"); - if (raw_a == null) throw new IllegalArgumentException("Missing req param: a"); - var a = raw_a instanceof Number ? ((Number) raw_a).intValue() : Integer.parseInt(raw_a.toString()); - var raw_b = args.get("b"); - if (raw_b == null) throw new IllegalArgumentException("Missing req param: b"); - var b = raw_b instanceof Number ? ((Number) raw_b).intValue() : Integer.parseInt(raw_b.toString()); - var result = c.add(a, b); - return new io.jooby.mcp.McpResult(this.json).toCallToolResult(result, false); - } - - private io.modelcontextprotocol.spec.McpSchema.Prompt reviewCodePromptSpec() { - var args = new java.util.ArrayList(); - args.add(new io.modelcontextprotocol.spec.McpSchema.PromptArgument("language", null, false)); - args.add(new io.modelcontextprotocol.spec.McpSchema.PromptArgument("code", null, false)); - return new io.modelcontextprotocol.spec.McpSchema.Prompt("review_code", null, "", args); - } - - private io.modelcontextprotocol.spec.McpSchema.GetPromptResult reviewCode(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.spec.McpSchema.GetPromptRequest req) { - var ctx = (io.jooby.Context) exchange.transportContext().get("CTX"); - var args = req.arguments() != null ? req.arguments() : java.util.Collections.emptyMap(); - var c = this.factory.apply(ctx); - var raw_language = args.get("language"); - var language = raw_language != null ? raw_language.toString() : null; - var raw_code = args.get("code"); - var code = raw_code != null ? raw_code.toString() : null; - var result = c.reviewCode(language, code); - return new io.jooby.mcp.McpResult(this.json).toPromptResult(result); - } - - private io.modelcontextprotocol.spec.McpSchema.Resource getLogsResourceSpec() { - return new io.modelcontextprotocol.spec.McpSchema.Resource("file:///logs/app.log", "getLogs", null, "", null, null, null, null); - } - - private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getLogs(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest req) { - var ctx = (io.jooby.Context) exchange.transportContext().get("CTX"); - var args = java.util.Collections.emptyMap(); - var c = this.factory.apply(ctx); - var result = c.getLogs(); - return new io.jooby.mcp.McpResult(this.json).toResourceResult(req.uri(), result); - } - - private io.modelcontextprotocol.spec.McpSchema.ResourceTemplate getUserProfileResourceTemplateSpec() { - return new io.modelcontextprotocol.spec.McpSchema.ResourceTemplate("file:///users/{id}/{name}/profile", "getUserProfile", null, "", null, null, null); - } - - private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getUserProfile(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest req) { - var ctx = (io.jooby.Context) exchange.transportContext().get("CTX"); - var uri = req.uri(); - var manager = new io.modelcontextprotocol.util.DefaultMcpUriTemplateManager("file:///users/{id}/{name}/profile"); - var args = new java.util.HashMap(); - args.putAll(manager.extractVariableValues(uri)); - var c = this.factory.apply(ctx); - var raw_id = args.get("id"); - var id = raw_id != null ? raw_id.toString() : null; - var result = c.getUserProfile(id); - return new io.jooby.mcp.McpResult(this.json).toResourceResult(req.uri(), result); - } - - private io.modelcontextprotocol.spec.McpSchema.CompleteResult getUserProfileCompletionHandler(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) { - var ctx = (io.jooby.Context) exchange.transportContext().get("CTX"); - var c = this.factory.apply(ctx); - var targetArg = req.argument() != null ? req.argument().name() : ""; - var typedValue = req.argument() != null ? req.argument().value() : ""; - return switch (targetArg) { - case "id" -> { - var result = c.completeUserId(typedValue); - yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); - } - case "name" -> { - var result = c.completeUserName(typedValue); - yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); - } - default -> new io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of()); - }; - } - - private io.modelcontextprotocol.spec.McpSchema.CompleteResult reviewCodeCompletionHandler(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) { - var ctx = (io.jooby.Context) exchange.transportContext().get("CTX"); - var c = this.factory.apply(ctx); - var targetArg = req.argument() != null ? req.argument().name() : ""; - var typedValue = req.argument() != null ? req.argument().value() : ""; - return switch (targetArg) { - case "language" -> { - var result = c.reviewCodelanguage(typedValue); - yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); - } - default -> new io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of()); - }; - } + protected java.util.function.Function factory; + + public ExampleServerMcp_() { + this(io.jooby.SneakyThrows.singleton(ExampleServer::new)); + } + + public ExampleServerMcp_(ExampleServer instance) { + setup(ctx -> instance); + } + + public ExampleServerMcp_(io.jooby.SneakyThrows.Supplier provider) { + setup(ctx -> (ExampleServer) provider.get()); + } + + public ExampleServerMcp_(io.jooby.SneakyThrows.Function, ExampleServer> provider) { + setup(ctx -> provider.apply(ExampleServer.class)); + } + + private void setup(java.util.function.Function factory) { + this.factory = factory; + } + + private io.modelcontextprotocol.json.McpJsonMapper json; + @Override + public void capabilities(io.modelcontextprotocol.spec.McpSchema.ServerCapabilities.Builder capabilities) { + capabilities.tools(true); + capabilities.prompts(true); + capabilities.resources(true, true); + capabilities.completions(); + } + + @Override + public String serverName() { + return "example-server"; + } + + @Override + public java.util.List completions() { + var completions = new java.util.ArrayList(); + completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.ResourceReference("file:///users/{id}/{name}/profile"), (exchange, req) -> this.getUserProfileCompletionHandler(exchange, null, req))); + completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), (exchange, req) -> this.reviewCodeCompletionHandler(exchange, null, req))); + return completions; + } + + @Override + public java.util.List statelessCompletions() { + var completions = new java.util.ArrayList(); + completions.add(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.ResourceReference("file:///users/{id}/{name}/profile"), (ctx, req) -> this.getUserProfileCompletionHandler(null, ctx, req))); + completions.add(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), (ctx, req) -> this.reviewCodeCompletionHandler(null, ctx, req))); + return completions; + } + + @Override + public void install(io.jooby.Jooby app, io.modelcontextprotocol.server.McpSyncServer server) throws Exception { + this.json = app.getServices().require(io.modelcontextprotocol.json.McpJsonMapper.class); + var mapper = app.require(tools.jackson.databind.ObjectMapper.class); + var configBuilder = new com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder(com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12, com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON); + var schemaGenerator = new com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build()); + + server.addTool(new io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification(addToolSpec(mapper, schemaGenerator), (exchange, req) -> this.add(exchange, null, req))); + server.addPrompt(new io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification(reviewCodePromptSpec(), (exchange, req) -> this.reviewCode(exchange, null, req))); + server.addResource(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification(getLogsResourceSpec(), (exchange, req) -> this.getLogs(exchange, null, req))); + server.addResourceTemplate(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceTemplateSpecification(getUserProfileResourceTemplateSpec(), (exchange, req) -> this.getUserProfile(exchange, null, req))); + } + + @Override + public void install(io.jooby.Jooby app, io.modelcontextprotocol.server.McpStatelessSyncServer server) throws Exception { + this.json = app.getServices().require(io.modelcontextprotocol.json.McpJsonMapper.class); + var mapper = app.require(tools.jackson.databind.ObjectMapper.class); + var configBuilder = new com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder(com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12, com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON); + var schemaGenerator = new com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build()); + + server.addTool(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncToolSpecification(addToolSpec(mapper, schemaGenerator), (ctx, req) -> this.add(null, ctx, req))); + server.addPrompt(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncPromptSpecification(reviewCodePromptSpec(), (ctx, req) -> this.reviewCode(null, ctx, req))); + server.addResource(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceSpecification(getLogsResourceSpec(), (ctx, req) -> this.getLogs(null, ctx, req))); + server.addResourceTemplate(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceTemplateSpecification(getUserProfileResourceTemplateSpec(), (ctx, req) -> this.getUserProfile(null, ctx, req))); + } + + private io.modelcontextprotocol.spec.McpSchema.Tool addToolSpec(tools.jackson.databind.ObjectMapper mapper, com.github.victools.jsonschema.generator.SchemaGenerator schemaGenerator) { + var schema = mapper.createObjectNode(); + schema.put("type", "object"); + var props = schema.putObject("properties"); + var req = schema.putArray("required"); + props.set("a", schemaGenerator.generateSchema(int.class)); + req.add("a"); + props.set("b", schemaGenerator.generateSchema(int.class)); + req.add("b"); + return new io.modelcontextprotocol.spec.McpSchema.Tool("calculator", null, "A simple calculator", mapper.treeToValue(schema, io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), null, null, null); + } + + private io.modelcontextprotocol.spec.McpSchema.CallToolResult add(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.CallToolRequest req) { + var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var args = req.arguments() != null ? req.arguments() : java.util.Collections.emptyMap(); + var c = this.factory.apply(ctx); + var raw_a = args.get("a"); + if (raw_a == null) throw new IllegalArgumentException("Missing req param: a"); + var a = raw_a instanceof Number ? ((Number) raw_a).intValue() : Integer.parseInt(raw_a.toString()); + var raw_b = args.get("b"); + if (raw_b == null) throw new IllegalArgumentException("Missing req param: b"); + var b = raw_b instanceof Number ? ((Number) raw_b).intValue() : Integer.parseInt(raw_b.toString()); + var result = c.add(a, b); + return new io.jooby.mcp.McpResult(this.json).toCallToolResult(result, false); + } + + private io.modelcontextprotocol.spec.McpSchema.Prompt reviewCodePromptSpec() { + var args = new java.util.ArrayList(); + args.add(new io.modelcontextprotocol.spec.McpSchema.PromptArgument("language", null, false)); + args.add(new io.modelcontextprotocol.spec.McpSchema.PromptArgument("code", null, false)); + return new io.modelcontextprotocol.spec.McpSchema.Prompt("review_code", null, "", args); + } + + private io.modelcontextprotocol.spec.McpSchema.GetPromptResult reviewCode(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.GetPromptRequest req) { + var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var args = req.arguments() != null ? req.arguments() : java.util.Collections.emptyMap(); + var c = this.factory.apply(ctx); + var raw_language = args.get("language"); + var language = raw_language != null ? raw_language.toString() : null; + var raw_code = args.get("code"); + var code = raw_code != null ? raw_code.toString() : null; + var result = c.reviewCode(language, code); + return new io.jooby.mcp.McpResult(this.json).toPromptResult(result); + } + + private io.modelcontextprotocol.spec.McpSchema.Resource getLogsResourceSpec() { + return new io.modelcontextprotocol.spec.McpSchema.Resource("file:///logs/app.log", "getLogs", null, "", null, null, null, null); + } + + private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getLogs(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest req) { + var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var args = java.util.Collections.emptyMap(); + var c = this.factory.apply(ctx); + var result = c.getLogs(); + return new io.jooby.mcp.McpResult(this.json).toResourceResult(req.uri(), result); + } + + private io.modelcontextprotocol.spec.McpSchema.ResourceTemplate getUserProfileResourceTemplateSpec() { + return new io.modelcontextprotocol.spec.McpSchema.ResourceTemplate("file:///users/{id}/{name}/profile", "getUserProfile", null, "", null, null, null); + } + + private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getUserProfile(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest req) { + var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var uri = req.uri(); + var manager = new io.modelcontextprotocol.util.DefaultMcpUriTemplateManager("file:///users/{id}/{name}/profile"); + var args = new java.util.HashMap(); + args.putAll(manager.extractVariableValues(uri)); + var c = this.factory.apply(ctx); + var raw_id = args.get("id"); + var id = raw_id != null ? raw_id.toString() : null; + var result = c.getUserProfile(id); + return new io.jooby.mcp.McpResult(this.json).toResourceResult(req.uri(), result); + } + + private io.modelcontextprotocol.spec.McpSchema.CompleteResult getUserProfileCompletionHandler(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) { + var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var c = this.factory.apply(ctx); + var targetArg = req.argument() != null ? req.argument().name() : ""; + var typedValue = req.argument() != null ? req.argument().value() : ""; + return switch (targetArg) { + case "id" -> { + var result = c.completeUserId(typedValue); + yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); + } + case "name" -> { + var result = c.completeUserName(typedValue); + yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); + } + default -> new io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of()); + }; + } + + private io.modelcontextprotocol.spec.McpSchema.CompleteResult reviewCodeCompletionHandler(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) { + var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var c = this.factory.apply(ctx); + var targetArg = req.argument() != null ? req.argument().name() : ""; + var typedValue = req.argument() != null ? req.argument().value() : ""; + return switch (targetArg) { + case "language" -> { + var result = c.reviewCodelanguage(typedValue); + yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); + } + default -> new io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of()); + }; + } } """); }); diff --git a/modules/jooby-mcp/pom.xml b/modules/jooby-mcp/pom.xml index 4f65a7a135..b498459f2b 100644 --- a/modules/jooby-mcp/pom.xml +++ b/modules/jooby-mcp/pom.xml @@ -24,11 +24,12 @@ io.modelcontextprotocol.sdk - mcp-core + mcp - io.modelcontextprotocol.sdk - mcp-json-jackson2 + com.github.victools + jsonschema-generator + 5.0.0 diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpServerConfig.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpServerConfig.java index 805b108718..e5fbedc2f7 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpServerConfig.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpServerConfig.java @@ -7,6 +7,7 @@ import com.typesafe.config.Config; import io.jooby.exception.StartupException; +import io.jooby.mcp.McpModule; /** * @author kliushnichenko @@ -18,7 +19,7 @@ public class McpServerConfig { private String name; private String version; - private Transport transport; + private McpModule.Transport transport; private String sseEndpoint; private String messageEndpoint; private String mcpEndpoint = DEFAULT_MCP_ENDPOINT; @@ -31,31 +32,6 @@ public McpServerConfig(String name, String version) { this.version = version; } - public enum Transport { - SSE("sse"), - STREAMABLE_HTTP("streamable-http"), - STATELESS_STREAMABLE_HTTP("stateless-streamable-http"); - - private final String value; - - Transport(String value) { - this.value = value; - } - - public static Transport of(String value) { - for (Transport transport : values()) { - if (transport.value.equalsIgnoreCase(value)) { - return transport; - } - } - throw new IllegalArgumentException("Unknown transport value: " + value); - } - - public String getValue() { - return value; - } - } - public String getName() { return name; } @@ -72,11 +48,11 @@ public void setVersion(String version) { this.version = version; } - public Transport getTransport() { + public McpModule.Transport getTransport() { return transport; } - public void setTransport(Transport transport) { + public void setTransport(McpModule.Transport transport) { this.transport = transport; } @@ -128,16 +104,16 @@ public void setInstructions(String instructions) { this.instructions = instructions; } - public static McpServerConfig fromConfig(Config config) { + public static McpServerConfig fromConfig(String key, Config config) { var srvConfig = new McpServerConfig( resolveRequiredParam(config, "name"), resolveRequiredParam(config, "version")); if (config.hasPath("transport")) { - Transport transport = Transport.of(config.getString("transport")); + McpModule.Transport transport = McpModule.Transport.of(config.getString("transport")); srvConfig.setTransport(transport); } else { - srvConfig.setTransport(Transport.STREAMABLE_HTTP); + srvConfig.setTransport(McpModule.Transport.STREAMABLE_HTTP); } srvConfig.setSseEndpoint(getStrProp("sseEndpoint", DEFAULT_SSE_ENDPOINT, config)); @@ -151,7 +127,7 @@ public static McpServerConfig fromConfig(Config config) { } public boolean isSseTransport() { - return this.transport == Transport.SSE; + return this.transport == McpModule.Transport.SSE; } private static String resolveRequiredParam(Config config, String configPath) { diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpSyncServerRunner.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpSyncServerRunner.java index 6f41c24b1d..d5c3f483b1 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpSyncServerRunner.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpSyncServerRunner.java @@ -15,6 +15,7 @@ import io.jooby.Jooby; import io.jooby.ServiceKey; import io.jooby.mcp.JoobyMcpServer; +import io.jooby.mcp.McpModule; import io.jooby.mcp.transport.JoobySseTransportProvider; import io.jooby.mcp.transport.JoobyStreamableServerTransportProvider; import io.modelcontextprotocol.json.McpJsonMapper; @@ -43,15 +44,15 @@ public McpSyncServerRunner( protected McpSyncServer initMcpServer() { List completions = initCompletions(); - if (McpServerConfig.Transport.SSE == serverConfig.getTransport()) { - var transportProvider = new JoobySseTransportProvider(app, serverConfig, mcpJsonMapper); + if (McpModule.Transport.SSE == serverConfig.getTransport()) { + var transportProvider = new JoobySseTransportProvider(app, serverConfig, mcpJsonMapper, null); return McpServer.sync(transportProvider) .serverInfo(serverConfig.getName(), serverConfig.getVersion()) .capabilities(computeCapabilities()) .completions(completions) .instructions(serverConfig.getInstructions()) .build(); - } else if (McpServerConfig.Transport.STREAMABLE_HTTP == serverConfig.getTransport()) { + } else if (McpModule.Transport.STREAMABLE_HTTP == serverConfig.getTransport()) { var transportProvider = new JoobyStreamableServerTransportProvider( app, mcpJsonMapper, serverConfig, CTX_EXTRACTOR); diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java index f9757cac7f..35b14d7112 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java @@ -5,15 +5,14 @@ */ package io.jooby.mcp; -import static io.jooby.internal.mcp.McpServerConfig.Transport.STATELESS_STREAMABLE_HTTP; +import static io.jooby.SneakyThrows.throwingConsumer; +import static io.jooby.mcp.McpModule.Transport.STATELESS_STREAMABLE_HTTP; +import static io.jooby.mcp.McpModule.Transport.STREAMABLE_HTTP; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; +import java.util.*; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.typesafe.config.Config; import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.Context; import io.jooby.Extension; import io.jooby.Jooby; import io.jooby.exception.StartupException; @@ -21,8 +20,15 @@ import io.jooby.internal.mcp.McpServerConfig; import io.jooby.internal.mcp.McpStatelessServerRunner; import io.jooby.internal.mcp.McpSyncServerRunner; +import io.jooby.mcp.transport.JoobySseTransportProvider; +import io.jooby.mcp.transport.JoobyStatelessServerTransport; +import io.jooby.mcp.transport.JoobyStreamableServerTransportProvider; +import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonMapper; -import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; +import io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapper; +import io.modelcontextprotocol.server.*; +import io.modelcontextprotocol.spec.McpSchema; +import tools.jackson.databind.json.JsonMapper; /** * MCP (Model Context Protocol) module for Jooby. @@ -109,37 +115,107 @@ */ public class McpModule implements Extension { + protected static final McpTransportContextExtractor CTX_EXTRACTOR = + ctx -> { + var transportContext = Map.of("HEADERS", ctx.headerMap()); + return McpTransportContext.create(transportContext); + }; + private static final String MODULE_CONFIG_PREFIX = "mcp"; - private McpJsonMapper mcpJsonMapper = new JacksonMcpJsonMapper(new ObjectMapper()); - private final List mcpServers = new ArrayList<>(); + private Transport defaultTransport = STREAMABLE_HTTP; + + private McpJsonMapper mcpJsonMapper; + private final List mcpServices = new ArrayList<>(); - public McpModule(JoobyMcpServer joobyMcpServer, JoobyMcpServer... moreMcpServers) { - mcpServers.add(joobyMcpServer); - if (moreMcpServers != null) { - Collections.addAll(mcpServers, moreMcpServers); + public McpModule(McpService mcpService, McpService... mcpServices) { + this.mcpServices.add(mcpService); + if (mcpServices != null) { + Collections.addAll(this.mcpServices, mcpServices); } } + public McpModule transport(@NonNull Transport transport) { + this.defaultTransport = transport; + return this; + } + @Override public void install(@NonNull Jooby app) { - Config config = app.getConfig(); - if (!config.hasPath(MODULE_CONFIG_PREFIX)) { - throw new StartupException("Missing required config path: " + MODULE_CONFIG_PREFIX); + var services = app.getServices(); + if (mcpJsonMapper == null) { + this.mcpJsonMapper = new JacksonMcpJsonMapper(services.require(JsonMapper.class)); } + services.put(McpJsonMapper.class, mcpJsonMapper); + var mcpServiceMap = new HashMap>(); + for (var mcpService : mcpServices) { + var serverKey = Optional.ofNullable(mcpService.serverName()).orElse("default"); + mcpServiceMap.computeIfAbsent(serverKey, k -> new ArrayList<>()).add(mcpService); + } + for (var serverEntry : mcpServiceMap.entrySet()) { + var mcpConfig = mcpServerConfig(app, serverEntry.getKey()); + var capabilities = new McpSchema.ServerCapabilities.Builder(); + serverEntry.getValue().forEach(it -> it.capabilities(capabilities)); - for (var joobyMcpServer : mcpServers) { - var serverConfig = resolveServerConfig(config, joobyMcpServer.getServerKey()); - // Load definitions from generated code. - joobyMcpServer.init(app, mcpJsonMapper); - - // transport + dedicated mcp server - var runner = buildMcpServerRunner(app, joobyMcpServer, serverConfig); - runner.run(); - app.getServices().listOf(McpServerConfig.class).add(serverConfig); + if (mcpConfig.getTransport() == STATELESS_STREAMABLE_HTTP) { + var transport = + new JoobyStatelessServerTransport(app, mcpJsonMapper, mcpConfig, CTX_EXTRACTOR); + var statelessServer = + McpServer.sync(transport) + .serverInfo(mcpConfig.getName(), mcpConfig.getVersion()) + .completions(statelessCompletions(serverEntry)) + .capabilities(capabilities.build()) + .instructions(mcpConfig.getInstructions()) + .build(); + serverEntry + .getValue() + .forEach(throwingConsumer(service -> service.install(app, statelessServer))); + app.onStop(statelessServer::close); + } else { + // Stupid MCP types, but it's the only way to make it work. + var syncServer = + (switch (mcpConfig.getTransport()) { + case STREAMABLE_HTTP -> + McpServer.sync( + new JoobyStreamableServerTransportProvider( + app, mcpJsonMapper, mcpConfig, CTX_EXTRACTOR)); + case SSE -> + McpServer.sync( + new JoobySseTransportProvider( + app, mcpConfig, mcpJsonMapper, CTX_EXTRACTOR)); + default -> + throw new IllegalStateException( + "Unsupported transport: " + mcpConfig.getTransport()); + }) + .serverInfo(mcpConfig.getName(), mcpConfig.getVersion()) + .completions(completions(serverEntry)) + .capabilities(capabilities.build()) + .instructions(mcpConfig.getInstructions()) + .build(); + serverEntry + .getValue() + .forEach(throwingConsumer(service -> service.install(app, syncServer))); + app.onStop(syncServer::close); + } } } + private static List completions( + Map.Entry> serverEntry) { + return serverEntry.getValue().stream() + .map(McpService::completions) + .flatMap(List::stream) + .toList(); + } + + private static List statelessCompletions( + Map.Entry> serverEntry) { + return serverEntry.getValue().stream() + .map(McpService::statelessCompletions) + .flatMap(List::stream) + .toList(); + } + private BaseMcpServerRunner buildMcpServerRunner( Jooby app, JoobyMcpServer joobyMcpServer, McpServerConfig serverConfig) { var isSingleServer = hasSingleMcpServer(); @@ -153,19 +229,53 @@ private BaseMcpServerRunner buildMcpServerRunner( } private boolean hasSingleMcpServer() { - return this.mcpServers.size() == 1; + return this.mcpServices.size() == 1; } - private McpServerConfig resolveServerConfig(Config config, String serverKey) { - String path = MODULE_CONFIG_PREFIX + "." + serverKey; - if (!config.hasPath(path)) { - throw new StartupException(String.format("Missing required config path: %s", path)); + private McpServerConfig mcpServerConfig(Jooby application, String key) { + var config = application.getConfig(); + var mcpPath = MODULE_CONFIG_PREFIX + "." + key; + if (config.hasPath(mcpPath)) { + return McpServerConfig.fromConfig(key, config.getConfig(mcpPath)); + } else if (key.equals("default")) { + var defaults = new McpServerConfig(application.getName(), application.getVersion()); + defaults.setTransport(defaultTransport); + defaults.setSseEndpoint(McpServerConfig.DEFAULT_SSE_ENDPOINT); + defaults.setMessageEndpoint(McpServerConfig.DEFAULT_MESSAGE_ENDPOINT); + defaults.setMcpEndpoint(McpServerConfig.DEFAULT_MCP_ENDPOINT); + return defaults; + } else { + throw new StartupException("Missing MCP server configuration: " + mcpPath); } - return McpServerConfig.fromConfig(config.getConfig(path)); } public McpModule mcpJsonMapper(McpJsonMapper mcpJsonMapper) { this.mcpJsonMapper = mcpJsonMapper; return this; } + + public enum Transport { + SSE("sse"), + STREAMABLE_HTTP("streamable-http"), + STATELESS_STREAMABLE_HTTP("stateless-streamable-http"); + + private final String value; + + Transport(String value) { + this.value = value; + } + + public static Transport of(String value) { + for (Transport transport : values()) { + if (transport.value.equalsIgnoreCase(value)) { + return transport; + } + } + throw new IllegalArgumentException("Unknown transport value: " + value); + } + + public String getValue() { + return value; + } + } } diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java index b2d3e54e41..7a2af765b4 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java @@ -9,19 +9,24 @@ import edu.umd.cs.findbugs.annotations.Nullable; import io.jooby.Jooby; -import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpStatelessServerFeatures; +import io.modelcontextprotocol.server.McpStatelessSyncServer; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.spec.McpSchema; /** High-performance dispatcher interface generated by the Jooby APT for MCP endpoints. */ public interface McpService { - void install(Jooby application, McpSyncServer server, McpJsonMapper json) throws Exception; + void install(Jooby application, McpSyncServer server) throws Exception; + + void install(Jooby application, McpStatelessSyncServer server) throws Exception; void capabilities(McpSchema.ServerCapabilities.Builder capabilities); List completions(); + List statelessCompletions(); + @Nullable String serverName(); } diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobySseTransportProvider.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobySseTransportProvider.java index 090b053dc7..a832d3c29f 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobySseTransportProvider.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobySseTransportProvider.java @@ -16,8 +16,10 @@ import io.jooby.*; import io.jooby.internal.mcp.McpServerConfig; +import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.server.McpTransportContextExtractor; import io.modelcontextprotocol.spec.*; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -37,6 +39,7 @@ public class JoobySseTransportProvider implements McpServerTransportProvider { private final String messageEndpoint; private final McpJsonMapper mcpJsonMapper; private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); + private final McpTransportContextExtractor contextExtractor; private McpServerSession.Factory sessionFactory; private final AtomicBoolean isClosing = new AtomicBoolean(false); @@ -49,9 +52,13 @@ public class JoobySseTransportProvider implements McpServerTransportProvider { * @param mcpJsonMapper The MCP JSON mapper for message serialization/deserialization */ public JoobySseTransportProvider( - Jooby app, McpServerConfig serverConfig, McpJsonMapper mcpJsonMapper) { + Jooby app, + McpServerConfig serverConfig, + McpJsonMapper mcpJsonMapper, + McpTransportContextExtractor contextExtractor) { this.mcpJsonMapper = mcpJsonMapper; this.messageEndpoint = serverConfig.getMessageEndpoint(); + this.contextExtractor = contextExtractor; String sseEndpoint = serverConfig.getSseEndpoint(); app.head(sseEndpoint, ctx -> StatusCode.OK).produces(TEXT_EVENT_STREAM); @@ -67,12 +74,12 @@ public void setSessionFactory(McpServerSession.Factory sessionFactory) { @Override public Mono notifyClients(String method, Object params) { if (sessions.isEmpty()) { - LOG.debug("No active sessions to broadcast message to"); + LOG.debug("No active sessions to broadcast a message to"); return Mono.empty(); } if (LOG.isDebugEnabled()) { - LOG.debug("Attempting to broadcast message to {} active sessions", sessions.size()); + LOG.debug("Attempting to broadcast a message to {} active sessions", sessions.size()); } return Flux.fromIterable(sessions.values()) @@ -151,12 +158,18 @@ private Object handleMessage(Context ctx) { } try { + McpTransportContext transportContext = this.contextExtractor.extract(ctx); var body = ctx.body().value(); McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(this.mcpJsonMapper, body); return session .handle(message) + .contextWrite( + reactorCtx -> + reactorCtx + .put(io.modelcontextprotocol.common.McpTransportContext.KEY, transportContext) + .put("CTX", ctx)) .then(Mono.just((Object) StatusCode.OK)) .onErrorResume( error -> { diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobyStreamableServerTransportProvider.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobyStreamableServerTransportProvider.java index 0c4fb62c0e..615e86a855 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobyStreamableServerTransportProvider.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobyStreamableServerTransportProvider.java @@ -95,7 +95,7 @@ private Context handleGet(Context ctx) { return SendError.invalidAcceptHeader(ctx, List.of(TEXT_EVENT_STREAM)); } - McpTransportContext transportContext = this.contextExtractor.extract(ctx); + var transportContext = this.contextExtractor.extract(ctx); if (ctx.header(HttpHeaders.MCP_SESSION_ID).isMissing()) { return SendError.missingSessionId(ctx); @@ -127,7 +127,10 @@ private Context handleGet(Context ctx) { session .replay(lastId) .contextWrite( - reactorCtx -> reactorCtx.put(McpTransportContext.KEY, transportContext)) + reactorCtx -> + reactorCtx + .put(McpTransportContext.KEY, transportContext) + .put("CTX", ctx)) .toIterable() .forEach( message -> { diff --git a/tests/pom.xml b/tests/pom.xml index f9bbb8ffce..71f573db99 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -146,6 +146,11 @@ jooby-avaje-validator ${jooby.version} + + io.jooby + jooby-mcp + ${jooby.version} + io.jooby jooby-test diff --git a/tests/src/test/java/io/jooby/i3830/CalculatorTools.java b/tests/src/test/java/io/jooby/i3830/CalculatorTools.java new file mode 100644 index 0000000000..c98a0d8350 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3830/CalculatorTools.java @@ -0,0 +1,55 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3830; + +import java.util.List; + +import io.jooby.annotation.McpCompletion; +import io.jooby.annotation.McpPrompt; +import io.jooby.annotation.McpResource; +import io.jooby.annotation.McpTool; + +/** A collection of tools, prompts, and resources exposed to the LLM via MCP. */ +public class CalculatorTools { + + // --- TOOLS --- + @McpTool(name = "add_numbers", description = "Adds two integers together and returns the result.") + public int add(int a, int b) { + return a + b; + } + + // --- PROMPTS --- + @McpPrompt(name = "math_tutor", description = "A prompt to initiate a math tutoring session") + public String mathTutor(String topic) { + return "You are a helpful math tutor. Please explain the concept of " + + topic + + " step by step."; + } + + // --- RESOURCES --- + @McpResource( + value = "calculator://manual/usage", + name = "Calculator Manual", + description = "Instructions on how to use the calculator") + public String manual() { + return "The Calculator supports basic arithmetic. Use the add_numbers tool to sum values."; + } + + @McpResource( + value = "calculator://history/{user}", + name = "User History", + description = "Retrieves the calculation history for a specific user") + public String history(String user) { + return "History for " + user + ":\n5 + 10 = 15\n2 * 4 = 8"; + } + + // --- COMPLETIONS --- + @McpCompletion(ref = "calculator://history/{user}") + public List historyCompletions(String user) { + // In a real app, this would query a database for active usernames matching the input + return List.of("alice", "bob", "charlie"); + } +} diff --git a/tests/src/test/java/io/jooby/i3830/CalculatorToolsTest.java b/tests/src/test/java/io/jooby/i3830/CalculatorToolsTest.java new file mode 100644 index 0000000000..a4ec8b80ee --- /dev/null +++ b/tests/src/test/java/io/jooby/i3830/CalculatorToolsTest.java @@ -0,0 +1,209 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3830; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.jooby.Jooby; +import io.jooby.jackson3.Jackson3Module; +import io.jooby.junit.ServerTest; +import io.jooby.junit.ServerTestRunner; +import io.jooby.mcp.McpModule; + +public class CalculatorToolsTest { + + private void setupMcpApp(Jooby app) { + app.install(new Jackson3Module()); + app.install( + new McpModule(new CalculatorToolsMcp_()) + .transport(McpModule.Transport.STATELESS_STREAMABLE_HTTP)); + } + + @ServerTest + public void shouldCallAddNumbersTool(ServerTestRunner runner) { + runner + .define(this::setupMcpApp) + .ready( + client -> { + String jsonRpcRequest = + """ + { + "jsonrpc": "2.0", + "id": "req-tool-1", + "method": "tools/call", + "params": { + "name": "add_numbers", + "arguments": { "a": 5, "b": 10 } + } + } + """; + + client.header("Content-Type", "application/json"); + client.postJson( + "/mcp", + jsonRpcRequest, + response -> { + assertEquals(200, response.code()); + String body = response.body().string(); + assertTrue( + body.contains("\"id\":\"req-tool-1\"") + || body.contains("\"id\": \"req-tool-1\"")); + assertTrue(body.contains("15"), "Tool execution should return 15"); + }); + }); + } + + @ServerTest + public void shouldGetMathTutorPrompt(ServerTestRunner runner) { + runner + .define(this::setupMcpApp) + .ready( + client -> { + String jsonRpcRequest = + """ + { + "jsonrpc": "2.0", + "id": "req-prompt-1", + "method": "prompts/get", + "params": { + "name": "math_tutor", + "arguments": { "topic": "Algebra" } + } + } + """; + + client.header("Content-Type", "application/json"); + client.postJson( + "/mcp", + jsonRpcRequest, + response -> { + assertEquals(200, response.code()); + String body = response.body().string(); + assertTrue( + body.contains("\"id\":\"req-prompt-1\"") + || body.contains("\"id\": \"req-prompt-1\"")); + assertTrue( + body.contains("explain the concept of Algebra"), + "Prompt should contain the formatted topic"); + }); + }); + } + + @ServerTest + public void shouldReadStaticResource(ServerTestRunner runner) { + runner + .define(this::setupMcpApp) + .ready( + client -> { + String jsonRpcRequest = + """ + { + "jsonrpc": "2.0", + "id": "req-res-1", + "method": "resources/read", + "params": { + "uri": "calculator://manual/usage" + } + } + """; + + client.header("Content-Type", "application/json"); + client.postJson( + "/mcp", + jsonRpcRequest, + response -> { + assertEquals(200, response.code()); + String body = response.body().string(); + assertTrue( + body.contains("\"id\":\"req-res-1\"") + || body.contains("\"id\": \"req-res-1\"")); + assertTrue( + body.contains("Calculator supports basic arithmetic"), + "Resource should return the manual text"); + }); + }); + } + + @ServerTest + public void shouldReadResourceTemplate(ServerTestRunner runner) { + runner + .define(this::setupMcpApp) + .ready( + client -> { + String jsonRpcRequest = + """ + { + "jsonrpc": "2.0", + "id": "req-res-2", + "method": "resources/read", + "params": { + "uri": "calculator://history/alice" + } + } + """; + + client.header("Content-Type", "application/json"); + client.postJson( + "/mcp", + jsonRpcRequest, + response -> { + assertEquals(200, response.code()); + String body = response.body().string(); + assertTrue( + body.contains("\"id\":\"req-res-2\"") + || body.contains("\"id\": \"req-res-2\"")); + assertTrue( + body.contains("History for alice"), + "Resource template should correctly extract the 'alice' path variable"); + }); + }); + } + + @ServerTest + public void shouldGetHistoryCompletion(ServerTestRunner runner) { + runner + .define(this::setupMcpApp) + .ready( + client -> { + String jsonRpcRequest = + """ + { + "jsonrpc": "2.0", + "id": "req-comp-1", + "method": "completion/complete", + "params": { + "ref": { + "type": "ref/resource", + "uri": "calculator://history/{user}" + }, + "argument": { + "name": "user", + "value": "al" + } + } + } + """; + + client.header("Content-Type", "application/json"); + client.postJson( + "/mcp", + jsonRpcRequest, + response -> { + assertEquals(200, response.code()); + String body = response.body().string(); + assertTrue( + body.contains("\"id\":\"req-comp-1\"") + || body.contains("\"id\": \"req-comp-1\"")); + assertTrue( + body.contains("alice"), + "Completion should return 'alice' in the values array"); + assertTrue( + body.contains("bob"), "Completion should return 'bob' in the values array"); + }); + }); + } +} diff --git a/tests/src/test/java/io/jooby/i3830/McpTest.java b/tests/src/test/java/io/jooby/i3830/McpTest.java new file mode 100644 index 0000000000..7dbb4d5b18 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3830/McpTest.java @@ -0,0 +1,8 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3830; + +public class McpTest {} From eb773edf516ae7438e890cc9a12bd5eae0fd30ab Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Fri, 27 Mar 2026 13:59:04 -0300 Subject: [PATCH 14/37] - integration test for all transport - add web-socket transport --- .../src/main/java/io/jooby/mcp/McpModule.java | 8 +- ...JoobyWebSocketServerTransportProvider.java | 241 +++++++++++++ .../io/jooby/i3830/CalculatorToolsTest.java | 326 +++++++++++++++++- 3 files changed, 572 insertions(+), 3 deletions(-) create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobyWebSocketServerTransportProvider.java diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java index 35b14d7112..6ff84d175b 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java @@ -23,6 +23,7 @@ import io.jooby.mcp.transport.JoobySseTransportProvider; import io.jooby.mcp.transport.JoobyStatelessServerTransport; import io.jooby.mcp.transport.JoobyStreamableServerTransportProvider; +import io.jooby.mcp.transport.JoobyWebSocketServerTransportProvider; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapper; @@ -183,6 +184,10 @@ public void install(@NonNull Jooby app) { McpServer.sync( new JoobySseTransportProvider( app, mcpConfig, mcpJsonMapper, CTX_EXTRACTOR)); + case WEBSOCKET -> + McpServer.sync( + new JoobyWebSocketServerTransportProvider( + app, mcpConfig, mcpJsonMapper, CTX_EXTRACTOR)); default -> throw new IllegalStateException( "Unsupported transport: " + mcpConfig.getTransport()); @@ -257,7 +262,8 @@ public McpModule mcpJsonMapper(McpJsonMapper mcpJsonMapper) { public enum Transport { SSE("sse"), STREAMABLE_HTTP("streamable-http"), - STATELESS_STREAMABLE_HTTP("stateless-streamable-http"); + STATELESS_STREAMABLE_HTTP("stateless-streamable-http"), + WEBSOCKET("websocket"); private final String value; diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobyWebSocketServerTransportProvider.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobyWebSocketServerTransportProvider.java new file mode 100644 index 0000000000..49574e43c8 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobyWebSocketServerTransportProvider.java @@ -0,0 +1,241 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.mcp.transport; + +import java.io.IOException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.jooby.Context; +import io.jooby.Jooby; +import io.jooby.WebSocket; +import io.jooby.WebSocketCloseStatus; +import io.jooby.WebSocketMessage; +import io.jooby.internal.mcp.McpServerConfig; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.server.McpTransportContextExtractor; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpServerSession; +import io.modelcontextprotocol.spec.McpServerTransport; +import io.modelcontextprotocol.spec.McpServerTransportProvider; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Provides WebSocket transport implementation for MCP server using Jooby framework. Handles + * bidirectional client connections, message routing, and session management. + */ +@SuppressWarnings("PMD") +public class JoobyWebSocketServerTransportProvider implements McpServerTransportProvider { + + private static final Logger LOG = + LoggerFactory.getLogger(JoobyWebSocketServerTransportProvider.class); + private static final String MCP_SESSION_ATTRIBUTE = "mcpSessionId"; + + private final McpJsonMapper mcpJsonMapper; + private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); + private final McpTransportContextExtractor contextExtractor; + + private McpServerSession.Factory sessionFactory; + private final AtomicBoolean isClosing = new AtomicBoolean(false); + + /** + * Constructs a new Jooby WebSocket transport provider instance. + * + * @param app The Jooby application instance to register endpoints with + * @param serverConfig The MCP server configuration containing endpoint settings + * @param mcpJsonMapper The MCP JSON mapper for message serialization/deserialization + * @param contextExtractor The extractor for transport context + */ + public JoobyWebSocketServerTransportProvider( + Jooby app, + McpServerConfig serverConfig, + McpJsonMapper mcpJsonMapper, + McpTransportContextExtractor contextExtractor) { + this.mcpJsonMapper = mcpJsonMapper; + this.contextExtractor = contextExtractor; + + String wsEndpoint = serverConfig.getMcpEndpoint(); + + app.ws( + wsEndpoint, + (ctx, ws) -> { + ws.onConnect(this::handleConnect); + ws.onMessage(this::handleMessage); + ws.onClose(this::handleClose); + ws.onError(this::handleError); + }); + } + + @Override + public void setSessionFactory(McpServerSession.Factory sessionFactory) { + this.sessionFactory = sessionFactory; + } + + @Override + public Mono notifyClients(String method, Object params) { + if (sessions.isEmpty()) { + LOG.debug("No active WebSocket sessions to broadcast a message to"); + return Mono.empty(); + } + + if (LOG.isDebugEnabled()) { + LOG.debug("Attempting to broadcast a message to {} active WS sessions", sessions.size()); + } + + return Flux.fromIterable(sessions.values()) + .flatMap( + session -> + session + .sendNotification(method, params) + .doOnError( + e -> + LOG.error( + "Failed to send message to WS session {}: {}", + session.getId(), + e.getMessage())) + .onErrorComplete()) + .then(); + } + + @Override + public Mono closeGracefully() { + return Flux.fromIterable(sessions.values()) + .doFirst( + () -> { + isClosing.set(true); + if (LOG.isDebugEnabled()) { + LOG.debug( + "Initiating graceful shutdown with {} active WS sessions", sessions.size()); + } + }) + .flatMap(McpServerSession::closeGracefully) + .doFinally(signalType -> sessions.clear()) + .then(); + } + + private void handleConnect(WebSocket ws) { + if (isClosing.get()) { + ws.close(WebSocketCloseStatus.SERVICE_RESTARTED); + return; + } + + JoobyMcpWebSocketTransport transport = new JoobyMcpWebSocketTransport(ws); + McpServerSession session = sessionFactory.create(transport); + String sessionId = session.getId(); + + ws.attribute(MCP_SESSION_ATTRIBUTE, sessionId); + sessions.put(sessionId, session); + + LOG.debug("New WebSocket connection established. Session ID: {}", sessionId); + } + + private void handleMessage(WebSocket ws, WebSocketMessage msg) { + String sessionId = ws.attribute(MCP_SESSION_ATTRIBUTE); + if (sessionId == null) { + LOG.warn("Received message on WebSocket without an associated MCP session"); + return; + } + + McpServerSession session = sessions.get(sessionId); + if (session == null) { + LOG.warn("Received message for unknown WS session ID: {}", sessionId); + return; + } + + try { + Context ctx = ws.getContext(); + McpTransportContext transportContext = this.contextExtractor.extract(ctx); + String body = msg.value(); + + McpSchema.JSONRPCMessage message = + McpSchema.deserializeJsonRpcMessage(this.mcpJsonMapper, body); + + // Unlike HTTP POSTs, WebSockets are fully asynchronous streams, so we just subscribe + // rather than blocking and returning an HTTP StatusCode. + session + .handle(message) + .contextWrite( + reactorCtx -> + reactorCtx + .put(io.modelcontextprotocol.common.McpTransportContext.KEY, transportContext) + .put("CTX", ctx)) + .subscribe( + null, + error -> + LOG.error( + "Error processing WS message for session {}: {}", + sessionId, + error.getMessage())); + } catch (IOException | IllegalArgumentException e) { + LOG.error("Failed to deserialize WS message: {}", e.getMessage()); + } + } + + private void handleClose(WebSocket ws, WebSocketCloseStatus status) { + String sessionId = ws.attribute(MCP_SESSION_ATTRIBUTE); + if (sessionId != null) { + LOG.debug( + "WebSocket connection closed for session: {} with status: {}", + sessionId, + status.getCode()); + sessions.remove(sessionId); + } + } + + private void handleError(WebSocket ws, Throwable cause) { + String sessionId = ws.attribute(MCP_SESSION_ATTRIBUTE); + LOG.error("WebSocket error for session: {}", sessionId, cause); + } + + private class JoobyMcpWebSocketTransport implements McpServerTransport { + + private final WebSocket ws; + private volatile boolean closed = false; + + public JoobyMcpWebSocketTransport(WebSocket ws) { + this.ws = ws; + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + return Mono.fromRunnable( + () -> { + try { + if (!closed) { + String jsonText = mcpJsonMapper.writeValueAsString(message); + ws.send(jsonText); + } + } catch (Exception e) { + LOG.error("Failed to send WebSocket message: {}", e.getMessage()); + } + }); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return mcpJsonMapper.convertValue(data, typeRef); + } + + @Override + public Mono closeGracefully() { + return Mono.fromRunnable(this::close); + } + + @Override + public void close() { + if (!closed) { + closed = true; + ws.close(WebSocketCloseStatus.NORMAL); + } + } + } +} diff --git a/tests/src/test/java/io/jooby/i3830/CalculatorToolsTest.java b/tests/src/test/java/io/jooby/i3830/CalculatorToolsTest.java index a4ec8b80ee..4bdcd986c6 100644 --- a/tests/src/test/java/io/jooby/i3830/CalculatorToolsTest.java +++ b/tests/src/test/java/io/jooby/i3830/CalculatorToolsTest.java @@ -5,8 +5,18 @@ */ package io.jooby.i3830; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.WebSocket; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import io.jooby.Jooby; import io.jooby.jackson3.Jackson3Module; @@ -23,6 +33,203 @@ private void setupMcpApp(Jooby app) { .transport(McpModule.Transport.STATELESS_STREAMABLE_HTTP)); } + @ServerTest + public void shouldCallToolOverStreamableHttp(ServerTestRunner runner) { + runner + .define( + app -> { + app.install(new Jackson3Module()); + // Register the module using the STREAMABLE_HTTP transport + app.install( + new McpModule(new CalculatorToolsMcp_()) + .transport(McpModule.Transport.STREAMABLE_HTTP)); + }) + .ready( + client -> { + AtomicReference sessionId = new AtomicReference<>(); + + // 1. STREAMABLE_HTTP requires a formal MCP initialize handshake via POST + // to generate the session ID. + String initRequest = + """ + { + "jsonrpc": "2.0", + "id": "init-1", + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "test-client", "version": "1.0.0" } + } + } + """; + + // The transport provider strictly requires these Accept headers + client.header("Accept", "text/event-stream, application/json"); + client.header("Content-Type", "application/json"); + + client.postJson( + "/mcp", + initRequest, + response -> { + assertEquals(200, response.code()); + + // 2. Extract the session ID from the headers + String header = response.header("mcp-session-id"); + assertNotNull( + header, "mcp-session-id header must be present in initialization response"); + sessionId.set(header); + }); + + // 3. Construct the Tool request + String toolRequest = + """ + { + "jsonrpc": "2.0", + "id": "tool-1", + "method": "tools/call", + "params": { + "name": "add_numbers", + "arguments": { "a": 5, "b": 10 } + } + } + """; + + // 4. Send the tool request, appending the session ID we just obtained + client.header("Accept", "text/event-stream, application/json"); + client.header("Content-Type", "application/json"); + client.header("mcp-session-id", sessionId.get()); + + client.postJson( + "/mcp", + toolRequest, + response -> { + assertEquals(200, response.code()); + + var body = response.body().string(); + + assertThat(body) + .contains("\"id\":\"tool-1\"") + .contains("15") + .as("The response should contain the calculated result"); + ; + }); + }); + } + + @ServerTest + public void shouldCallToolOverSse(ServerTestRunner runner) { + runner + .define( + app -> { + app.install(new Jackson3Module()); + app.install( + new McpModule(new CalculatorToolsMcp_()).transport(McpModule.Transport.SSE)); + }) + .ready( + client -> { + String initRequest = + """ + { + "jsonrpc": "2.0", + "id": "init-1", + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "test-client", "version": "1.0.0" } + } + } + """; + + String initializedNotification = + """ + { + "jsonrpc": "2.0", + "method": "notifications/initialized" + } + """; + + String toolRequest = + """ + { + "jsonrpc": "2.0", + "id": "tool-1", + "method": "tools/call", + "params": { + "name": "add_numbers", + "arguments": { "a": 5, "b": 10 } + } + } + """; + + client + .sse("/mcp/sse") + .next( + event -> { + assertThat(event.getEvent()).isEqualTo("endpoint"); + + // 1. Get the endpoint and trim any sneaky SSE newlines + String endpoint = event.getData().toString().trim(); + String absoluteUri = + endpoint.startsWith("http") + ? endpoint + : "http://localhost:" + runner.getAllocatedPort() + endpoint; + + // 2. Fire the POSTs in a background thread to prevent blocking the SSE + // listener + Thread.startVirtualThread( + () -> { + try (var httpClient = HttpClient.newHttpClient()) { + // Step A: Send Initialize + HttpRequest req1 = + HttpRequest.newBuilder(URI.create(absoluteUri)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(initRequest)) + .build(); + httpClient.send(req1, HttpResponse.BodyHandlers.discarding()); + + // Step B: Send Initialized Notification (Required by spec) + HttpRequest req2 = + HttpRequest.newBuilder(URI.create(absoluteUri)) + .header("Content-Type", "application/json") + .POST( + HttpRequest.BodyPublishers.ofString( + initializedNotification)) + .build(); + httpClient.send(req2, HttpResponse.BodyHandlers.discarding()); + + // Step C: Finally, call the tool! + HttpRequest req3 = + HttpRequest.newBuilder(URI.create(absoluteUri)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(toolRequest)) + .build(); + httpClient.send(req3, HttpResponse.BodyHandlers.discarding()); + + } catch (Exception e) { + e.printStackTrace(); + } + }); + }) + .next( + event -> { + // 3. The first message back is the result of our 'initialize' handshake + assertThat(event.getEvent()).isEqualTo("message"); + assertThat(event.getData().toString()).contains("\"id\":\"init-1\""); + }) + .next( + event -> { + // 4. The second message back is the actual tool execution result + assertThat(event.getEvent()).isEqualTo("message"); + assertThat(event.getData().toString()) + .containsPattern("\"id\":\\s*\"tool-1\"") + .contains("15"); + }) + .verify(); + }); + } + @ServerTest public void shouldCallAddNumbersTool(ServerTestRunner runner) { runner @@ -57,6 +264,121 @@ public void shouldCallAddNumbersTool(ServerTestRunner runner) { }); } + @ServerTest + public void shouldCallToolOverWebSocket(ServerTestRunner runner) throws Exception { + runner + .define( + app -> { + app.install(new Jackson3Module()); + // Register the module using our brand new WEBSOCKET transport! + app.install( + new McpModule(new CalculatorToolsMcp_()) + .transport(McpModule.Transport.WEBSOCKET)); + }) + .ready( + client -> { + CountDownLatch initLatch = new CountDownLatch(1); + CountDownLatch toolLatch = new CountDownLatch(1); + + AtomicReference initResponse = new AtomicReference<>(); + AtomicReference toolResponse = new AtomicReference<>(); + + // 1. Connect to the Jooby WS endpoint using Java's native HttpClient + String wsUri = "ws://localhost:" + runner.getAllocatedPort() + "/mcp"; + HttpClient httpClient = HttpClient.newHttpClient(); + + WebSocket webSocket = + httpClient + .newWebSocketBuilder() + .buildAsync( + URI.create(wsUri), + new WebSocket.Listener() { + StringBuilder textBuilder = new StringBuilder(); + + @Override + public CompletionStage onText( + WebSocket ws, CharSequence data, boolean last) { + textBuilder.append(data); + if (last) { + String message = textBuilder.toString(); + textBuilder.setLength(0); // reset buffer + + // Route the incoming messages to the correct assertions + if (message.contains("\"id\":\"init-1\"") + || message.contains("\"id\": \"init-1\"")) { + initResponse.set(message); + initLatch.countDown(); + } else if (message.contains("\"id\":\"tool-1\"") + || message.contains("\"id\": \"tool-1\"")) { + toolResponse.set(message); + toolLatch.countDown(); + } + } + return WebSocket.Listener.super.onText(ws, data, last); + } + }) + .join(); + + // 2. Send the Initialize Request + String initRequest = + """ + { + "jsonrpc": "2.0", + "id": "init-1", + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "test-client", "version": "1.0.0" } + } + } + """; + webSocket.sendText(initRequest, true).join(); + + // Wait up to 2 seconds for the server to reply to the initialization + assertThat(initLatch.await(2, TimeUnit.SECONDS)).isTrue(); + assertThat(initResponse.get()).contains("\"id\":\"init-1\""); + + // 3. Send the Initialized Notification + // (This is fire-and-forget! The server sends nothing back for notifications) + String initializedNotification = + """ + { + "jsonrpc": "2.0", + "method": "notifications/initialized" + } + """; + webSocket.sendText(initializedNotification, true).join(); + + // 4. Send the Tool Execution Request + String toolRequest = + """ + { + "jsonrpc": "2.0", + "id": "tool-1", + "method": "tools/call", + "params": { + "name": "add_numbers", + "arguments": { "a": 5, "b": 10 } + } + } + """; + webSocket.sendText(toolRequest, true).join(); + + // Wait up to 2 seconds for the tool result + assertThat(toolLatch.await(2, TimeUnit.SECONDS)).isTrue(); + + // 5. Verify the tool successfully executed over the socket! + assertThat(toolResponse.get()) + .containsPattern("\"id\":\\s*\"tool-1\"") + .contains("15") + .as("The response should contain the calculated result: 15"); + + // Clean up the connection + webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "Test complete").join(); + }); + } + @ServerTest public void shouldGetMathTutorPrompt(ServerTestRunner runner) { runner From b590548f961316d5282d1d053dd3e2deb359fabd Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Fri, 27 Mar 2026 14:03:03 -0300 Subject: [PATCH 15/37] - remove code examples from kliushnichenko --- modules/jooby-mcp/src/ExampleMcpServer.java | 214 ------------------ modules/jooby-mcp/src/ToolsExample.java | 46 ---- modules/jooby-mcp/src/WeatherServer.java | 22 -- modules/jooby-mcp/src/WeatherService.java | 13 -- .../internal/mcp/BaseMcpServerRunner.java | 123 ---------- .../internal/mcp/McpCompletionHandler.java | 74 ------ .../jooby/internal/mcp/McpPromptHandler.java | 96 -------- .../internal/mcp/McpResourceHandler.java | 84 ------- .../mcp/McpResourceTemplateHandler.java | 57 ----- .../mcp/McpStatelessServerRunner.java | 142 ------------ .../internal/mcp/McpSyncServerRunner.java | 167 -------------- .../io/jooby/internal/mcp/McpToolHandler.java | 100 -------- .../io/jooby/internal/mcp/MethodInvoker.java | 25 -- .../java/io/jooby/internal/mcp/ToolSpec.java | 121 ---------- .../java/io/jooby/mcp/JoobyMcpServer.java | 45 ---- .../src/main/java/io/jooby/mcp/McpModule.java | 19 -- .../main/java/io/jooby/mcp/ResourceUri.java | 13 -- .../java/io/jooby/mcp/WeatherMcpServer.java | 177 --------------- .../test/java/io/jooby/mcp/WeatherServer.java | 12 - 19 files changed, 1550 deletions(-) delete mode 100644 modules/jooby-mcp/src/ExampleMcpServer.java delete mode 100644 modules/jooby-mcp/src/ToolsExample.java delete mode 100644 modules/jooby-mcp/src/WeatherServer.java delete mode 100644 modules/jooby-mcp/src/WeatherService.java delete mode 100644 modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/BaseMcpServerRunner.java delete mode 100644 modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpCompletionHandler.java delete mode 100644 modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpPromptHandler.java delete mode 100644 modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpResourceHandler.java delete mode 100644 modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpResourceTemplateHandler.java delete mode 100644 modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpStatelessServerRunner.java delete mode 100644 modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpSyncServerRunner.java delete mode 100644 modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpToolHandler.java delete mode 100644 modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/MethodInvoker.java delete mode 100644 modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/ToolSpec.java delete mode 100644 modules/jooby-mcp/src/main/java/io/jooby/mcp/JoobyMcpServer.java delete mode 100644 modules/jooby-mcp/src/main/java/io/jooby/mcp/ResourceUri.java delete mode 100644 modules/jooby-mcp/src/test/java/io/jooby/mcp/WeatherMcpServer.java delete mode 100644 modules/jooby-mcp/src/test/java/io/jooby/mcp/WeatherServer.java diff --git a/modules/jooby-mcp/src/ExampleMcpServer.java b/modules/jooby-mcp/src/ExampleMcpServer.java deleted file mode 100644 index a042c5a194..0000000000 --- a/modules/jooby-mcp/src/ExampleMcpServer.java +++ /dev/null @@ -1,214 +0,0 @@ -// This file is generated by McpToolProcessor. Do not modify manually. -package io.github.kliushnichenko.jooby.mcp.example; - -import io.github.kliushnichenko.jooby.mcp.JoobyMcpServer; -import io.github.kliushnichenko.jooby.mcp.ResourceUri; -import io.github.kliushnichenko.jooby.mcp.internal.MethodInvoker; -import io.github.kliushnichenko.jooby.mcp.internal.ToolSpec; -import io.jooby.Jooby; -import io.modelcontextprotocol.json.McpJsonMapper; -import io.modelcontextprotocol.server.McpSyncServerExchange; -import io.modelcontextprotocol.spec.McpSchema; -import java.lang.Object; -import java.lang.String; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.function.Function; -import java.util.function.Supplier; - -/** - * Generated Jooby MCP Server. Do not modify manually. - */ -public class ExampleMcpServer implements JoobyMcpServer { - private Jooby app; - - private McpJsonMapper mcpJsonMapper; - - /** - * Map of tool names to its specification. - */ - private final Map tools = new HashMap<>(); - - /** - * Map of tool names to method invokers. - */ - private final Map toolInvokers = new HashMap<>(); - - /** - * Map of prompt names to its specification. - */ - private final Map prompts = new HashMap<>(); - - /** - * Map of prompt names to method invokers. - */ - private final Map promptInvokers = new HashMap<>(); - - /** - * List of completions reference objects. - */ - private final List completions = new ArrayList<>(); - - /** - * Map of completion key(a composition of _) to method invoker. - */ - private final Map> completionInvokers = new HashMap<>(); - - /** - * List of resources. - */ - private final List resources = new ArrayList<>(); - - /** - * Map of resource URI to method invoker. - */ - private final Map> resourceReaders = new HashMap<>(); - - /** - * List of resource templates. - */ - private final List resourceTemplates = new ArrayList<>(); - - /** - * Map of resource URI template to method invoker. - */ - private final Map, Object>> resourceTemplateReaders = new HashMap<>(); - - /** - * Initialize a new server. - * @param app the Jooby application instance - * @param mcpJsonMapper json serializer instance - */ - public void init(final Jooby app, final McpJsonMapper mcpJsonMapper) { - this.app = app; - this.mcpJsonMapper = mcpJsonMapper; - - tools.put("elicitation_example", ToolSpec.builder().name("elicitation_example").description("Request the username over elicitation").inputSchema("{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}").requiredArguments(List.of()).build()); - tools.put("add", ToolSpec.builder().name("add").description("Adds two numbers together").inputSchema("{\"type\":\"object\",\"properties\":{\"first\":{\"type\":\"integer\",\"description\":\"First number to add\"},\"second\":{\"type\":\"integer\",\"description\":\"Second number to add\"}},\"required\":[\"first\",\"second\"],\"additionalProperties\":false}").outputSchema("{\"type\":\"object\",\"properties\":{\"operation\":{\"type\":\"string\"},\"result\":{\"type\":\"number\"},\"expression\":{\"type\":\"string\"}},\"required\":[\"operation\",\"result\",\"expression\"],\"additionalProperties\":false}").requiredArguments(List.of("first", "second")).build()); - tools.put("subtract", ToolSpec.builder().name("subtract").inputSchema("{\"type\":\"object\",\"properties\":{\"a\":{\"type\":\"integer\"},\"b\":{\"type\":\"integer\"}},\"required\":[\"a\",\"b\"],\"additionalProperties\":false}").requiredArguments(List.of("a", "b")).build()); - tools.put("pi_sign_image", ToolSpec.builder().name("pi_sign_image").description("Returns an image of the Pi").inputSchema("{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}").requiredArguments(List.of()).build()); - tools.put("get_client_info", ToolSpec.builder().name("get_client_info").description("Returns the information about the client initiated the request").inputSchema("{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}").requiredArguments(List.of()).build()); - - toolInvokers.put("elicitation_example", (args, exchange) -> app.require(ElicitationExample.class).requestUsername(exchange)); - toolInvokers.put("add", (args, exchange) -> app.require(ToolsExample.class).add((int) args.get("first"), (int) args.get("second"))); - toolInvokers.put("subtract", (args, exchange) -> app.require(ToolsExample.class).subtract((int) args.get("a"), (int) args.get("b"), exchange)); - toolInvokers.put("pi_sign_image", (args, exchange) -> app.require(ToolsExample.class).getPiSignImage()); - toolInvokers.put("get_client_info", (args, exchange) -> app.require(ToolsExample.class).getClientInfo(exchange)); - - prompts.put("summarizeText", new McpSchema.Prompt("summarizeText", "", "Summarizes the provided text into a specified number of sentences", List.of(new McpSchema.PromptArgument("text_to_summarize", null, true), new McpSchema.PromptArgument("maxSentences", null, true)))); - prompts.put("code_review", new McpSchema.Prompt("code_review", "", "Code Review Prompt", List.of(new McpSchema.PromptArgument("codeSnippet", null, true), new McpSchema.PromptArgument("language", null, true), new McpSchema.PromptArgument("scrutinyLevel", null, true)))); - - promptInvokers.put("summarizeText", (args, exchange) -> app.require(PromptsExample.class).summarizeText((String) args.get("text_to_summarize"), (String) args.get("maxSentences"))); - promptInvokers.put("code_review", (args, exchange) -> app.require(PromptsExample.class).codeReviewPrompt((String) args.get("codeSnippet"), (String) args.get("language"), (String) args.get("scrutinyLevel"))); - completions.add(new McpSchema.PromptReference("code_review")); - completions.add(new McpSchema.PromptReference("code_review")); - completions.add(new McpSchema.ResourceReference("file:///project/{name}")); - - completionInvokers.put("code_review_language", (input) -> app.require(PromptsExample.class).completeCodeReviewLang(input)); - completionInvokers.put("code_review_scrutinyLevel", (input) -> app.require(PromptsExample.class).completeScrutinyLevel(input)); - completionInvokers.put("file:///project/{name}_name", (input) -> app.require(ResourceTemplateExamples.class).projectNameCompletion(input)); - - resources.add(McpSchema.Resource.builder().name("README.md").title("README.md").uri("file:///project/README.md").mimeType("text/markdown").build()); - resources.add(McpSchema.Resource.builder().name("blobResource").uri("file:///blob").build()); - resources.add(McpSchema.Resource.builder().name("threadStone").uri("file:///project/thread-stone").size(Long.valueOf(10563)).annotations(new McpSchema.Annotations(List.of(McpSchema.Role.USER, McpSchema.Role.ASSISTANT), 0.3, null)).build()); - resources.add(McpSchema.Resource.builder().name("blackBriar").uri("file:///project/blackbriar/metadata.json").build()); - - resourceReaders.put("file:///project/README.md", () -> app.require(ResourceExamples.class).textResource()); - resourceReaders.put("file:///blob", () -> app.require(ResourceExamples.class).blobResource()); - resourceReaders.put("file:///project/thread-stone", () -> app.require(ResourceExamples.class).threadStone()); - resourceReaders.put("file:///project/blackbriar/metadata.json", () -> app.require(ResourceExamples.class).blackBriar()); - - resourceTemplates.add(McpSchema.ResourceTemplate.builder().name("get_project").uriTemplate("file:///project/{name}").build()); - - resourceTemplateReaders.put("file:///project/{name}", (args) -> app.require(ResourceTemplateExamples.class).getProject((String) args.get("name"), new ResourceUri((String) args.get("__resourceUri")))); - - } - - /** - * Invokes a tool by name with the provided arguments. - * @param toolName the name of the tool to invoke - * @param args the arguments to pass to the tool - * @return the result of the tool invocation - */ - public Object invokeTool(final String toolName, final Map args, - final McpSyncServerExchange exchange) { - MethodInvoker invoker = toolInvokers.get(toolName); - return invoker.invoke(args, exchange); - } - - /** - * Invokes a prompt by name with the provided arguments. - * @param promptName the name of the prompt to invoke - * @param args the arguments to pass to the prompt - * @return the result of the prompt invocation - */ - public Object invokePrompt(final String promptName, final Map args, - final McpSyncServerExchange exchange) { - MethodInvoker invoker = promptInvokers.get(promptName); - return invoker.invoke(args, exchange); - } - - /** - * Invokes a completion by identifier(prompt or resource name) and argumentName with the provided - * argument value. - * @param identifier prompt or resource template name - * @param argumentName the name of an argument in prompt or resource template - * @param input incoming argument value - * @return the result of the completion invocation - */ - public Object invokeCompletion(final String identifier, final String argumentName, - final String input) { - var completionKey = identifier + '_' + argumentName; - var invoker = completionInvokers.get(completionKey); - if (invoker == null) { - return List.of(); - } - return invoker.apply(input); - } - - /** - * Reads a resource by URI - * @param uri Resource URI - * @return resource content - */ - public Object readResource(final String uri) { - var reader = resourceReaders.get(uri); - return reader.get(); - } - - /** - * Reads a resource by URI according to template - * @param uriTemplate Resource URI template - * @return resource content - */ - public Object readResourceByTemplate(final String uriTemplate, final Map args) { - var reader = resourceTemplateReaders.get(uriTemplate); - return reader.apply(args); - } - - public Map getTools() { - return tools; - } - - public Map getPrompts() { - return prompts; - } - - public List getCompletions() { - return completions; - } - - public List getResources() { - return resources; - } - - public List getResourceTemplates() { - return resourceTemplates; - } - - public String getServerKey() { - return "example"; - } -} diff --git a/modules/jooby-mcp/src/ToolsExample.java b/modules/jooby-mcp/src/ToolsExample.java deleted file mode 100644 index 11b2b1f790..0000000000 --- a/modules/jooby-mcp/src/ToolsExample.java +++ /dev/null @@ -1,46 +0,0 @@ -package io.github.kliushnichenko.jooby.mcp.example; - -import io.github.kliushnichenko.jooby.mcp.annotation.OutputSchema; -import io.github.kliushnichenko.jooby.mcp.annotation.Tool; -import io.github.kliushnichenko.jooby.mcp.annotation.ToolArg; -import io.modelcontextprotocol.server.McpSyncServerExchange; -import io.modelcontextprotocol.spec.McpSchema; -import jakarta.inject.Singleton; - -/** - * @author kliushnichenko - */ -@Singleton -public class ToolsExample { - - private static final String PI_SIGN_IMAGE = "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAB2AAAAdgB+lymcgAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAagSURBVHic5ZtrbBVFFMd/3VuLUNsGCxRQE7W0osXECALy0ERjTBSJb9FPvhElmojwRT9ojBAfqFFjRAwmisZoEEQhEURjfFEeRiiVp8EPBqVVWmhVENr64eyms7Ovuffu7t3qP5lk994zZ87szpw5ry0jedQB04HzgUagAagFaoBTbJpuoBM4BOyxWwvwFdCWgoyxYyLwIvAj0FdkawVeAC5KdQYFoBpYAOym+EkHtd32GNVxCV0WA49aYB5wP7Kso3AMOAB0AX/av1UCVcBpQIUBj07gVWAxsm1KAgu4F/id4Dd2HNgILAJmAPVALoRnzqaZYfdpBk6E8G8H7rZlSRUNyMSCBGsG5iKro1gMs3k1h4z3HTAmhrGMcAPQESDIemBygmNfALwP9PqMfQS4NcGxsRDNHvQGxic5uIYJBK+IxSSwJSqAd30G60L0QOp70B7zPsSG0OV6BzNlaoQKYI3PINuAc+IapAicixhNunxriOEhWPi/+RXA4GKZx4ghwEr8V0JRq9Nvzy8h/CgrFXLAUvx1QkG42YfZkqLFTBZlwOt45b4tX0ZjgMMak1VAeVySJogc8AFeZd1oysDCe8RsI1t7PgpD8CrGbzHUB7O1jt2Iph1oaESMI3Uud+pEujNUi3hcqgk7G9lXUcgB04CpwEhgUN4i54/fEFe5M+D/OYjT5KAdObo7ghguwv3ENhG9bCqAB21hknKDw9p7IbJZeH2WJ4OIq5En6RD2AhdGTH6UzwBpt+8jZJyI23c4hLjeHizQGH8UwXgYsK/Ek/8buDFCToBPtH7znT9UHbALt2k7FdGcQfgMuNzntzeB7cBfyu9fAqfb178AlwbwNKVz8CvyEKIwGXHYHOxFOxYn4X5CzREMr9XojwN3hNDvV2j3x0BXCLbglnk89Cs43Y9+K4LZPO1+PvLms4y3tXuXdahGb48j+zsIw3GHqfYQ7RtkYQWMwC13C8gKGAGMVQi3InG+IDThnvBKoCdOSRNCG/CDct8EjLCAS3Arwy8iGNVp9/uKly01bFCuy4DpFpKxUfF1BJNW+t94D+EnRdagz21cOV4vaWcEkx3A1cBVwFrkgQwU7NLuG8B9PBwlmWBHFpQgiDt/TBljk4Vb4x9gYCi0QnECMZ4cDLdw28Vd6cpTEnQr11UW/Slq/c//Ko4o11WliOdnChbakiiVIClCTa13Wbj3/f/hAahbvstCwkQORpHNmH9cKEfm6KDdQnxjB4OAM9OUKGWcjTtdtsdCvDkVAyUCfJnd8sFY7X6vhZi2KqYWLFIwTlauj8XAbyHi2GwAnsqj3zTtfgeIO6wGDTfGIKCKGsS6dPh/HkJragqrhVi6fR+GrUq/Xmx3uA23AzSB8IBIvrged2h9ewhtn3IdZqOoK8o0BV6HVJg4aAXanEHWKX/kgFmGTKNQCzyu/bYqhF6t+BqNpLh0VCLVZA7+MJRlFu6H+qn650TyC4qaYCTenMEmwkvzlmn0j/rQPKbRvGEoj7r8+/DJeezSCKYYMlZxBhLKXog7yeLEGqMU7BVanx7gJVuWKcDLuPVJH97QvB8ma318Yx56YmS1AWMHg5HIUFgSY64hr3URfNS21pCnXuLziB9RNe7yt15EIZpAzxOo7SjhOQMdwzCrMW7FrA5xEoapMZAzVR1kM2Y59XF4Kzp7gA8prJCqBskz6Mvd4bsMs7Jcv1qHJ1QCXSGdipyx6jE4B3jNYLArgZnIKvoJWZ4HDfqF4SzgGqRipQ+JQH8M/GzY/wHgFeW+DXkhQel0AO7B/cS6gfPyEDoraEKKsdW53G7S0UISiWrHFvzP5KyiEtER6hy+IY/q+Hq8x9hqBk6R1ArcsncinmBeuAWvAlpKPN8YJIUyxDDS5b6pUIbP+zBbSjaDJuX4T/7ZYphaSLmpznQl2dIJlUhFiy7ncmJYsUHF0i1kI3jShFfh9SEynxTXIBX4r4Ru5FuhUoTXc8g5rx91zpuPbfIOLKTw2M8s3Yx4lGlhEt6SF3XPJ6qor0Psab/B1wMXJzj2FMQS9Ptk5jDxxTEiUU+497cFeAgJtxWLOptX0Bt3jJy8z/liYQF3IfZ1kGAnkO3xNOInNBBuTJXbNDOBZ5BJ+zlETjuIeJoFL/k49spQ4GFEIQ01oP+H/g8nnbRcFZKxGY1ZjK8DCY6E1QmnjiqkXE6PLMXZdiLBjMyn8CYgJ8YO/BWWaetF7I3nSOizvDTs+uFIJdo43J/PD0UsOJCzvAOJ8O7F/fl8OwniX/VNNP8m9q82AAAAAElFTkSuQmCC"; - - public record ArithmeticResult(String operation, double result, String expression) { - } - - @Tool(name = "add", description = "Adds two numbers together") - public ArithmeticResult add( - @ToolArg(name = "first", description = "First number to add") int a, - @ToolArg(name = "second", description = "Second number to add") int b - ) { - int result = a + b; - return new ArithmeticResult("addition", result, a + " + " + b + " = " + result); - } - - @Tool - public String subtract(int a, int b, McpSyncServerExchange exchange) { - int result = a - b; - return String.valueOf(result); - } - - @Tool(name = "pi_sign_image", description = "Returns an image of the Pi") - public McpSchema.ImageContent getPiSignImage() { - return new McpSchema.ImageContent(null, PI_SIGN_IMAGE, "image/png"); - } - - @Tool(name = "get_client_info", description = "Returns the information about the client initiated the request") - @OutputSchema.Suppressed - public McpSchema.Implementation getClientInfo(McpSyncServerExchange exchange) { - return exchange.getClientInfo(); - } -} diff --git a/modules/jooby-mcp/src/WeatherServer.java b/modules/jooby-mcp/src/WeatherServer.java deleted file mode 100644 index 1b1047bcbe..0000000000 --- a/modules/jooby-mcp/src/WeatherServer.java +++ /dev/null @@ -1,22 +0,0 @@ -package io.github.kliushnichenko.jooby.mcp.example; - -import io.github.kliushnichenko.jooby.mcp.annotation.McpServer; -import io.github.kliushnichenko.jooby.mcp.annotation.Tool; -import jakarta.inject.Singleton; -import lombok.RequiredArgsConstructor; - -/** - * @author kliushnichenko - */ -@Singleton -@McpServer("weather") -@RequiredArgsConstructor -public class WeatherServer { - - private final WeatherService weatherService; - - @Tool(name = "get_weather") - public String getWeather(double latitude, double longitude) { - return weatherService.getWeather(latitude, longitude); - } -} diff --git a/modules/jooby-mcp/src/WeatherService.java b/modules/jooby-mcp/src/WeatherService.java deleted file mode 100644 index 371d67354c..0000000000 --- a/modules/jooby-mcp/src/WeatherService.java +++ /dev/null @@ -1,13 +0,0 @@ -package io.github.kliushnichenko.jooby.mcp.example; - -import jakarta.inject.Singleton; - -@Singleton -public class WeatherService { - - public String getWeather(double latitude, double longitude) { - // Simulate fetching weather data for the given location - // In a real application, this would involve calling a weather API - return "The weather in Numenor is sunny with a temperature of 25°C."; - } -} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/BaseMcpServerRunner.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/BaseMcpServerRunner.java deleted file mode 100644 index 4a36b25883..0000000000 --- a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/BaseMcpServerRunner.java +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.mcp; - -import java.util.Map; - -import io.jooby.Context; -import io.jooby.Jooby; -import io.jooby.mcp.JoobyMcpServer; -import io.modelcontextprotocol.common.McpTransportContext; -import io.modelcontextprotocol.json.McpJsonMapper; -import io.modelcontextprotocol.server.McpTransportContextExtractor; -import io.modelcontextprotocol.spec.McpSchema; - -public abstract class BaseMcpServerRunner { - - protected static final McpTransportContextExtractor CTX_EXTRACTOR = - ctx -> { - var transportContext = Map.of("HEADERS", ctx.headerMap()); - return McpTransportContext.create(transportContext); - }; - - protected final Jooby app; - protected final JoobyMcpServer joobyMcpServer; - protected final McpServerConfig serverConfig; - protected final McpJsonMapper mcpJsonMapper; - protected final boolean isSingleServer; - - protected final McpToolHandler toolHandler; - protected final McpResourceHandler resourceHandler; - protected final McpResourceTemplateHandler resourceTemplateHandler; - - public BaseMcpServerRunner( - Jooby app, - JoobyMcpServer joobyMcpServer, - McpServerConfig serverConfig, - McpJsonMapper mcpJsonMapper, - boolean isSingleServer) { - this.app = app; - this.joobyMcpServer = joobyMcpServer; - this.serverConfig = serverConfig; - this.mcpJsonMapper = mcpJsonMapper; - this.isSingleServer = isSingleServer; - - this.toolHandler = new McpToolHandler(mcpJsonMapper); - this.resourceHandler = new McpResourceHandler(mcpJsonMapper); - this.resourceTemplateHandler = new McpResourceTemplateHandler(mcpJsonMapper); - } - - public void run() { - S mcpServer = initMcpServer(); - - initTools(mcpServer); - initPrompts(mcpServer); - initResources(mcpServer); - initResourceTemplates(mcpServer); - - addToJoobyRegistry(mcpServer); - logMcpStart(mcpServer); - app.onStop(() -> close(mcpServer)); - } - - protected abstract S initMcpServer(); - - protected abstract void initTools(S mcpServer); - - protected abstract void initPrompts(S mcpServer); - - protected abstract void initResources(S mcpServer); - - protected abstract void initResourceTemplates(S mcpServer); - - protected abstract void logMcpStart(S mcpServer); - - protected abstract void addToJoobyRegistry(S mcpServer); - - protected abstract void close(S mcpServer); - - protected McpSchema.Tool buildTool(ToolSpec toolSpec) { - McpSchema.Tool.Builder toolBuilder = - McpSchema.Tool.builder() - .name(toolSpec.getName()) - .title(toolSpec.getTitle()) - .description(toolSpec.getDescription()) - .inputSchema(mcpJsonMapper, toolSpec.getInputSchema()); - - if (toolSpec.getOutputSchema() != null) { - toolBuilder.outputSchema(mcpJsonMapper, toolSpec.getOutputSchema()); - } - - if (toolSpec.getAnnotations() != null) { - toolBuilder.annotations(toolSpec.getAnnotations()); - } - - return toolBuilder.build(); - } - - @SuppressWarnings("PMD.NPathComplexity") - protected McpSchema.ServerCapabilities computeCapabilities() { - var builder = McpSchema.ServerCapabilities.builder(); - - if (!joobyMcpServer.getTools().isEmpty()) { - builder.tools(true); - } - - if (!joobyMcpServer.getPrompts().isEmpty()) { - builder.prompts(true); - } - - if (!joobyMcpServer.getCompletions().isEmpty()) { - builder.completions(); - } - - if (!joobyMcpServer.getResources().isEmpty()) { - builder.resources(true, true); - } - - return builder.build(); - } -} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpCompletionHandler.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpCompletionHandler.java deleted file mode 100644 index a1cc2fcd6b..0000000000 --- a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpCompletionHandler.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.mcp; - -import static io.modelcontextprotocol.spec.McpSchema.ErrorCodes.INTERNAL_ERROR; - -import java.util.List; -import java.util.Objects; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import io.jooby.mcp.JoobyMcpServer; -import io.modelcontextprotocol.spec.McpError; -import io.modelcontextprotocol.spec.McpSchema; - -/** - * @author kliushnichenko - */ -class McpCompletionHandler { - - private static final Logger LOG = LoggerFactory.getLogger(McpCompletionHandler.class); - - public static McpSchema.CompleteResult handle( - JoobyMcpServer server, McpSchema.CompleteRequest request) { - try { - var identifier = request.ref().identifier(); - var argName = request.argument().name(); - var argValue = request.argument().value(); - - Object result = server.invokeCompletion(identifier, argName, argValue); - - return toCompleteResult(result); - } catch (Exception ex) { - LOG.error("Error invoking prompt completion '{}':", request.ref().identifier(), ex); - throw new McpError( - new McpSchema.JSONRPCResponse.JSONRPCError(INTERNAL_ERROR, ex.getMessage(), null)); - } - } - - @SuppressWarnings("PMD.NcssCount") - private static McpSchema.CompleteResult toCompleteResult(Object result) { - Objects.requireNonNull(result, "Completion result cannot be null"); - - if (result instanceof McpSchema.CompleteResult completeResult) { - return completeResult; - } else if (result instanceof McpSchema.CompleteResult.CompleteCompletion completion) { - return new McpSchema.CompleteResult(completion); - } else if (result instanceof List values) { - if (values.isEmpty()) { - return new McpSchema.CompleteResult( - new McpSchema.CompleteResult.CompleteCompletion(List.of(), 0, false)); - } else { - var item = values.getFirst(); - if (item instanceof String) { - //noinspection unchecked - return new McpSchema.CompleteResult( - new McpSchema.CompleteResult.CompleteCompletion( - (List) values, values.size(), false)); - } - } - } else if (result instanceof String singleValue) { - var completion = - new McpSchema.CompleteResult.CompleteCompletion(List.of(singleValue), 1, false); - return new McpSchema.CompleteResult(completion); - } - - LOG.error("Unsupported completion result type: {}", result.getClass().getName()); - throw new IllegalStateException("Unexpected error occurred while handling completion result"); - } -} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpPromptHandler.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpPromptHandler.java deleted file mode 100644 index 14b78c0a97..0000000000 --- a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpPromptHandler.java +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.mcp; - -import static io.modelcontextprotocol.spec.McpSchema.ErrorCodes.INTERNAL_ERROR; -import static io.modelcontextprotocol.spec.McpSchema.ErrorCodes.INVALID_PARAMS; -import static io.modelcontextprotocol.spec.McpSchema.Role.USER; - -import java.util.List; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import io.jooby.mcp.JoobyMcpServer; -import io.modelcontextprotocol.server.McpSyncServerExchange; -import io.modelcontextprotocol.spec.McpError; -import io.modelcontextprotocol.spec.McpSchema; - -/** - * @author kliushnichenko - */ -class McpPromptHandler { - - private static final Logger LOG = LoggerFactory.getLogger(McpPromptHandler.class); - - public static McpSchema.GetPromptResult handle( - JoobyMcpServer server, McpSchema.GetPromptRequest request, McpSyncServerExchange exchange) { - var promptName = request.name(); - if (!server.getPrompts().containsKey(promptName)) { - throwUnknownPromptErr(promptName); - } - - try { - Object result = server.invokePrompt(promptName, request.arguments(), exchange); - return toPromptResult(result); - } catch (Exception ex) { - LOG.error("Error invoking prompt '{}':", request.name(), ex); - throw new McpError( - new McpSchema.JSONRPCResponse.JSONRPCError(INTERNAL_ERROR, ex.getMessage(), null)); - } - } - - @SuppressWarnings("PMD.NcssCount") - private static McpSchema.GetPromptResult toPromptResult(Object result) { - if (result == null) { - return new McpSchema.GetPromptResult(null, List.of()); - } else if (result instanceof McpSchema.GetPromptResult promptResult) { - return promptResult; - } else if (result instanceof McpSchema.PromptMessage promptMessage) { - return new McpSchema.GetPromptResult(null, List.of(promptMessage)); - } else if (result instanceof McpSchema.Content content) { - var promptMessage = new McpSchema.PromptMessage(USER, content); - return new McpSchema.GetPromptResult(null, List.of(promptMessage)); - } else if (result instanceof String str) { - var promptMessage = new McpSchema.PromptMessage(USER, new McpSchema.TextContent(str)); - return new McpSchema.GetPromptResult(null, List.of(promptMessage)); - } else if (result instanceof List items) { - //noinspection unchecked - return handleListReturnType((List) result, items); - } else { - var promptMessage = - new McpSchema.PromptMessage(USER, new McpSchema.TextContent(result.toString())); - return new McpSchema.GetPromptResult(null, List.of(promptMessage)); - } - } - - private static McpSchema.GetPromptResult handleListReturnType( - List result, List items) { - if (items.isEmpty()) { - return new McpSchema.GetPromptResult(null, List.of()); - } else { - var item = items.getFirst(); - if (item instanceof McpSchema.PromptMessage) { - return new McpSchema.GetPromptResult(null, result); - } else { - var msgs = - items.stream() - .map( - i -> new McpSchema.PromptMessage(USER, new McpSchema.TextContent(i.toString()))) - .toList(); - return new McpSchema.GetPromptResult(null, msgs); - } - } - } - - private static void throwUnknownPromptErr(String promptName) { - throw new McpError( - new McpSchema.JSONRPCResponse.JSONRPCError( - INVALID_PARAMS, - "Unknown prompt name '" + promptName + "'. Please verify such a prompt is registered.", - null)); - } -} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpResourceHandler.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpResourceHandler.java deleted file mode 100644 index e0c390db29..0000000000 --- a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpResourceHandler.java +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.mcp; - -import static io.modelcontextprotocol.spec.McpSchema.ErrorCodes.INTERNAL_ERROR; - -import java.io.IOException; -import java.util.List; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import io.jooby.mcp.JoobyMcpServer; -import io.modelcontextprotocol.json.McpJsonMapper; -import io.modelcontextprotocol.spec.McpError; -import io.modelcontextprotocol.spec.McpSchema; - -/** - * @author kliushnichenko - */ -class McpResourceHandler { - - private static final Logger LOG = LoggerFactory.getLogger(McpResourceHandler.class); - - private final McpJsonMapper mcpJsonMapper; - - public McpResourceHandler(McpJsonMapper mcpJsonMapper) { - this.mcpJsonMapper = mcpJsonMapper; - } - - public McpSchema.ReadResourceResult handle( - JoobyMcpServer server, McpSchema.ReadResourceRequest request) { - var uri = request.uri(); - - try { - Object result = server.readResource(uri); - return toResourceResult(result, uri, mcpJsonMapper); - } catch (Exception ex) { - LOG.error("Error reading resource by URI '{}':", uri, ex); - throw new McpError( - new McpSchema.JSONRPCResponse.JSONRPCError(INTERNAL_ERROR, ex.getMessage(), null)); - } - } - - static McpSchema.ReadResourceResult toResourceResult( - Object result, String uri, McpJsonMapper mcpJsonMapper) throws IOException { - if (result == null) { - return new McpSchema.ReadResourceResult(List.of()); - } else if (result instanceof McpSchema.ReadResourceResult resourceResult) { - return resourceResult; - } else if (result instanceof McpSchema.ResourceContents resourceContents) { - return new McpSchema.ReadResourceResult(List.of(resourceContents)); - } else if (result instanceof List contents) { - return handleListReturnType(result, uri, mcpJsonMapper, contents); - } else { - return toJsonResult(result, uri, mcpJsonMapper); - } - } - - private static McpSchema.ReadResourceResult handleListReturnType( - Object result, String uri, McpJsonMapper mcpJsonMapper, List contents) throws IOException { - if (contents.isEmpty()) { - return new McpSchema.ReadResourceResult(List.of()); - } else { - var item = contents.getFirst(); - if (item instanceof McpSchema.ResourceContents) { - //noinspection unchecked - return new McpSchema.ReadResourceResult((List) contents); - } else { - return toJsonResult(result, uri, mcpJsonMapper); - } - } - } - - static McpSchema.ReadResourceResult toJsonResult( - Object result, String uri, McpJsonMapper mcpJsonMapper) throws IOException { - var resultStr = mcpJsonMapper.writeValueAsString(result); - var content = new McpSchema.TextResourceContents(uri, "application/json", resultStr); - return new McpSchema.ReadResourceResult(List.of(content)); - } -} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpResourceTemplateHandler.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpResourceTemplateHandler.java deleted file mode 100644 index 35b549a720..0000000000 --- a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpResourceTemplateHandler.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.mcp; - -import static io.modelcontextprotocol.spec.McpSchema.ErrorCodes.INTERNAL_ERROR; - -import java.util.HashMap; -import java.util.Map; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import io.jooby.mcp.JoobyMcpServer; -import io.jooby.mcp.ResourceUri; -import io.modelcontextprotocol.json.McpJsonMapper; -import io.modelcontextprotocol.spec.McpError; -import io.modelcontextprotocol.spec.McpSchema; -import io.modelcontextprotocol.util.DefaultMcpUriTemplateManager; - -/** - * @author kliushnichenko - */ -class McpResourceTemplateHandler { - - private static final Logger LOG = LoggerFactory.getLogger(McpResourceTemplateHandler.class); - - private final McpJsonMapper mcpJsonMapper; - - public McpResourceTemplateHandler(McpJsonMapper mcpJsonMapper) { - this.mcpJsonMapper = mcpJsonMapper; - } - - public McpSchema.ReadResourceResult handle( - JoobyMcpServer server, - McpSchema.ResourceTemplate resourceTemplate, - McpSchema.ReadResourceRequest request) { - var uri = request.uri(); - var uriTemplate = resourceTemplate.uriTemplate(); - DefaultMcpUriTemplateManager manager = new DefaultMcpUriTemplateManager(uriTemplate); - - Map args = new HashMap<>(); - args.put(ResourceUri.CTX_KEY, uri); - args.putAll(manager.extractVariableValues(uri)); - - try { - Object result = server.readResourceByTemplate(uriTemplate, args); - return McpResourceHandler.toResourceResult(result, uri, mcpJsonMapper); - } catch (Exception ex) { - LOG.error("Error reading resource template by URI '{}':", uri, ex); - throw new McpError( - new McpSchema.JSONRPCResponse.JSONRPCError(INTERNAL_ERROR, ex.getMessage(), null)); - } - } -} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpStatelessServerRunner.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpStatelessServerRunner.java deleted file mode 100644 index d9fd72dbac..0000000000 --- a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpStatelessServerRunner.java +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.mcp; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import io.jooby.Jooby; -import io.jooby.ServiceKey; -import io.jooby.mcp.JoobyMcpServer; -import io.jooby.mcp.transport.JoobyStatelessServerTransport; -import io.modelcontextprotocol.json.McpJsonMapper; -import io.modelcontextprotocol.server.McpServer; -import io.modelcontextprotocol.server.McpStatelessServerFeatures; -import io.modelcontextprotocol.server.McpStatelessSyncServer; -import io.modelcontextprotocol.spec.McpSchema; - -/** - * @author kliushnichenko - */ -public class McpStatelessServerRunner extends BaseMcpServerRunner { - - private static final Logger LOG = LoggerFactory.getLogger(McpStatelessServerRunner.class); - - public McpStatelessServerRunner( - Jooby app, - JoobyMcpServer joobyMcpServer, - McpServerConfig serverConfig, - McpJsonMapper mcpJsonMapper, - boolean isSingleServer) { - super(app, joobyMcpServer, serverConfig, mcpJsonMapper, isSingleServer); - } - - @Override - protected McpStatelessSyncServer initMcpServer() { - List completions = initCompletions(); - - var transportProvider = - new JoobyStatelessServerTransport(app, mcpJsonMapper, serverConfig, CTX_EXTRACTOR); - return McpServer.sync(transportProvider) - .serverInfo(serverConfig.getName(), serverConfig.getVersion()) - .capabilities(computeCapabilities()) - .completions(completions) - .instructions(serverConfig.getInstructions()) - .build(); - } - - private List initCompletions() { - List completions = new ArrayList<>(); - for (McpSchema.CompleteReference ref : joobyMcpServer.getCompletions()) { - var completion = - new McpStatelessServerFeatures.SyncCompletionSpecification( - ref, (ctx, request) -> McpCompletionHandler.handle(joobyMcpServer, request)); - completions.add(completion); - } - return completions; - } - - @Override - protected void initTools(McpStatelessSyncServer mcpServer) { - for (Map.Entry entry : joobyMcpServer.getTools().entrySet()) { - ToolSpec toolSpec = entry.getValue(); - var syncToolSpec = - new McpStatelessServerFeatures.SyncToolSpecification.Builder() - .tool(buildTool(toolSpec)) - .callHandler((ctx, request) -> toolHandler.handle(request, joobyMcpServer, null)) - .build(); - - mcpServer.addTool(syncToolSpec); - } - } - - @Override - protected void initPrompts(McpStatelessSyncServer mcpServer) { - for (Map.Entry entry : joobyMcpServer.getPrompts().entrySet()) { - mcpServer.addPrompt( - new McpStatelessServerFeatures.SyncPromptSpecification( - entry.getValue(), - (ctx, request) -> McpPromptHandler.handle(joobyMcpServer, request, null))); - } - } - - @Override - protected void initResources(McpStatelessSyncServer mcpServer) { - for (McpSchema.Resource resource : joobyMcpServer.getResources()) { - mcpServer.addResource( - new McpStatelessServerFeatures.SyncResourceSpecification( - resource, (ctx, request) -> resourceHandler.handle(joobyMcpServer, request))); - } - } - - @Override - protected void initResourceTemplates(McpStatelessSyncServer mcpServer) { - for (McpSchema.ResourceTemplate template : joobyMcpServer.getResourceTemplates()) { - var syncTemplateSpec = - new McpStatelessServerFeatures.SyncResourceTemplateSpecification( - template, - (ctx, request) -> resourceTemplateHandler.handle(joobyMcpServer, template, request)); - mcpServer.addResourceTemplate(syncTemplateSpec); - } - } - - @Override - protected void addToJoobyRegistry(McpStatelessSyncServer mcpServer) { - var registry = app.getServices(); - if (isSingleServer) { - registry.put(McpStatelessSyncServer.class, mcpServer); - } else { - var serviceKey = ServiceKey.key(McpStatelessSyncServer.class, joobyMcpServer.getServerKey()); - registry.put(serviceKey, mcpServer); - } - } - - @Override - protected void close(McpStatelessSyncServer mcpServer) { - mcpServer.close(); - } - - @Override - protected void logMcpStart(McpStatelessSyncServer mcpServer) { - LOG.info( - """ - - MCP server started with: - name: {} - version: {} - transport: {} - capabilities: {} - """, - mcpServer.getServerInfo().name(), - mcpServer.getServerInfo().version(), - serverConfig.getTransport().getValue(), - mcpServer.getServerCapabilities()); - } -} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpSyncServerRunner.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpSyncServerRunner.java deleted file mode 100644 index d5c3f483b1..0000000000 --- a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpSyncServerRunner.java +++ /dev/null @@ -1,167 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.mcp; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import io.jooby.Jooby; -import io.jooby.ServiceKey; -import io.jooby.mcp.JoobyMcpServer; -import io.jooby.mcp.McpModule; -import io.jooby.mcp.transport.JoobySseTransportProvider; -import io.jooby.mcp.transport.JoobyStreamableServerTransportProvider; -import io.modelcontextprotocol.json.McpJsonMapper; -import io.modelcontextprotocol.server.McpServer; -import io.modelcontextprotocol.server.McpServerFeatures; -import io.modelcontextprotocol.server.McpSyncServer; -import io.modelcontextprotocol.spec.McpSchema; - -/** - * @author kliushnichenko - */ -public class McpSyncServerRunner extends BaseMcpServerRunner { - - private static final Logger LOG = LoggerFactory.getLogger(McpSyncServerRunner.class); - - public McpSyncServerRunner( - Jooby app, - JoobyMcpServer joobyMcpServer, - McpServerConfig serverConfig, - McpJsonMapper mcpJsonMapper, - boolean isSingleServer) { - super(app, joobyMcpServer, serverConfig, mcpJsonMapper, isSingleServer); - } - - @Override - protected McpSyncServer initMcpServer() { - List completions = initCompletions(); - - if (McpModule.Transport.SSE == serverConfig.getTransport()) { - var transportProvider = new JoobySseTransportProvider(app, serverConfig, mcpJsonMapper, null); - return McpServer.sync(transportProvider) - .serverInfo(serverConfig.getName(), serverConfig.getVersion()) - .capabilities(computeCapabilities()) - .completions(completions) - .instructions(serverConfig.getInstructions()) - .build(); - } else if (McpModule.Transport.STREAMABLE_HTTP == serverConfig.getTransport()) { - var transportProvider = - new JoobyStreamableServerTransportProvider( - app, mcpJsonMapper, serverConfig, CTX_EXTRACTOR); - - return McpServer.sync(transportProvider) - .serverInfo(serverConfig.getName(), serverConfig.getVersion()) - .capabilities(computeCapabilities()) - .completions(completions) - .instructions(serverConfig.getInstructions()) - .build(); - } else { - throw new IllegalStateException("Unsupported transport: " + serverConfig.getTransport()); - } - } - - private List initCompletions() { - List completions = new ArrayList<>(); - for (McpSchema.CompleteReference ref : joobyMcpServer.getCompletions()) { - var completion = - new McpServerFeatures.SyncCompletionSpecification( - ref, (exchange, request) -> McpCompletionHandler.handle(joobyMcpServer, request)); - completions.add(completion); - } - return completions; - } - - @Override - protected void initTools(McpSyncServer mcpServer) { - for (Map.Entry entry : joobyMcpServer.getTools().entrySet()) { - ToolSpec toolSpec = entry.getValue(); - - var syncToolSpec = - new McpServerFeatures.SyncToolSpecification.Builder() - .tool(buildTool(toolSpec)) - .callHandler( - (exchange, request) -> toolHandler.handle(request, joobyMcpServer, exchange)) - .build(); - - mcpServer.addTool(syncToolSpec); - } - } - - @Override - protected void initPrompts(McpSyncServer mcpServer) { - for (Map.Entry entry : joobyMcpServer.getPrompts().entrySet()) { - mcpServer.addPrompt( - new McpServerFeatures.SyncPromptSpecification( - entry.getValue(), - (exchange, request) -> McpPromptHandler.handle(joobyMcpServer, request, exchange))); - } - } - - @Override - protected void initResources(McpSyncServer mcpServer) { - for (McpSchema.Resource resource : joobyMcpServer.getResources()) { - mcpServer.addResource( - new McpServerFeatures.SyncResourceSpecification( - resource, (exchange, request) -> resourceHandler.handle(joobyMcpServer, request))); - } - } - - @Override - protected void initResourceTemplates(McpSyncServer mcpServer) { - for (McpSchema.ResourceTemplate template : joobyMcpServer.getResourceTemplates()) { - var syncTemplateSpec = - new McpServerFeatures.SyncResourceTemplateSpecification( - template, - (exchange, request) -> - resourceTemplateHandler.handle(joobyMcpServer, template, request)); - mcpServer.addResourceTemplate(syncTemplateSpec); - } - } - - @Override - protected void addToJoobyRegistry(McpSyncServer mcpServer) { - var registry = app.getServices(); - if (isSingleServer) { - registry.put(McpSyncServer.class, mcpServer); - } else { - var serviceKey = ServiceKey.key(McpSyncServer.class, joobyMcpServer.getServerKey()); - registry.put(serviceKey, mcpServer); - } - } - - @Override - protected void close(McpSyncServer mcpServer) { - mcpServer.close(); - } - - @Override - protected void logMcpStart(McpSyncServer mcpServer) { - LOG.info( - """ - - MCP server started with: - name: {} - version: {} - transport: {} - keepAliveInterval: {} - disallowDelete: {} - capabilities: {} - """, - mcpServer.getServerInfo().name(), - mcpServer.getServerInfo().version(), - serverConfig.getTransport().getValue(), - serverConfig.getKeepAliveInterval() == null - ? "N/A" - : serverConfig.getKeepAliveInterval() + " s", - serverConfig.isDisallowDelete(), - mcpServer.getServerCapabilities()); - } -} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpToolHandler.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpToolHandler.java deleted file mode 100644 index d3e32aa167..0000000000 --- a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpToolHandler.java +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.mcp; - -import static io.modelcontextprotocol.spec.McpSchema.ErrorCodes.INVALID_PARAMS; - -import java.io.IOException; -import java.util.List; -import java.util.Map; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import io.jooby.mcp.JoobyMcpServer; -import io.modelcontextprotocol.json.McpJsonMapper; -import io.modelcontextprotocol.server.McpSyncServerExchange; -import io.modelcontextprotocol.spec.McpError; -import io.modelcontextprotocol.spec.McpSchema; - -/** - * @author kliushnichenko - */ -public class McpToolHandler { - - private static final Logger LOG = LoggerFactory.getLogger(McpToolHandler.class); - - private final McpJsonMapper mcpJsonMapper; - - public McpToolHandler(McpJsonMapper mcpJsonMapper) { - this.mcpJsonMapper = mcpJsonMapper; - } - - public McpSchema.CallToolResult handle( - McpSchema.CallToolRequest request, JoobyMcpServer server, McpSyncServerExchange exchange) { - String toolName = request.name(); - ToolSpec toolSpec = server.getTools().get(toolName); - if (toolSpec == null) { - throwUnknownToolErr(toolName); - } - try { - verifyRequiredArguments(request.arguments(), toolSpec.getRequiredArguments()); - - Object result = server.invokeTool(toolName, request.arguments(), exchange); - return toCallToolResult(toolSpec, result); - } catch (Exception ex) { - LOG.error("Error invoking tool '{}':", toolName, ex); - return buildTextResult(ex.getMessage(), true); - } - } - - private McpSchema.CallToolResult toCallToolResult(ToolSpec spec, Object result) - throws IOException { - var hasOutputSchema = spec.getOutputSchema() != null; - if (result == null) { - return buildTextResult("null", false); - } else if (result instanceof McpSchema.CallToolResult callToolResult) { - return callToolResult; - } else if (result instanceof String str) { - return buildTextResult(str, false); - } else if (result instanceof McpSchema.Content content) { - return McpSchema.CallToolResult.builder().content(List.of(content)).isError(false).build(); - } else { - if (hasOutputSchema) { - return McpSchema.CallToolResult.builder().structuredContent(result).isError(false).build(); - } else { - var resultStr = mcpJsonMapper.writeValueAsString(result); - return buildTextResult(resultStr, false); - } - } - } - - private void verifyRequiredArguments( - Map actualArguments, List requiredArguments) { - for (String requiredArg : requiredArguments) { - var argument = actualArguments.get(requiredArg); - if (argument == null) { - throw new IllegalArgumentException("Missing required argument: " + requiredArg); - } - - if (argument instanceof String str && str.isEmpty()) { - throw new IllegalArgumentException("Required argument is empty: " + requiredArg); - } - } - } - - private McpSchema.CallToolResult buildTextResult(String text, boolean isError) { - return McpSchema.CallToolResult.builder().addTextContent(text).isError(isError).build(); - } - - private static void throwUnknownToolErr(String toolName) { - throw new McpError( - new McpSchema.JSONRPCResponse.JSONRPCError( - INVALID_PARAMS, - "Unknown tool '" + toolName + "'. Please verify such a tool is registered.", - null)); - } -} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/MethodInvoker.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/MethodInvoker.java deleted file mode 100644 index 3cd17b49c1..0000000000 --- a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/MethodInvoker.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.mcp; - -import java.util.Map; - -import io.modelcontextprotocol.server.McpSyncServerExchange; - -/** - * @author kliushnichenko - */ -@FunctionalInterface -public interface MethodInvoker { - /** - * Invokes a method with the provided arguments and exchange context. - * - * @param args a map of argument names to values - * @param exchange the server exchange context - * @return the result of the method invocation - */ - Object invoke(final Map args, McpSyncServerExchange exchange); -} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/ToolSpec.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/ToolSpec.java deleted file mode 100644 index 373d748d43..0000000000 --- a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/ToolSpec.java +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.mcp; - -import java.util.List; - -import io.modelcontextprotocol.spec.McpSchema; - -/** - * @author kliushnichenko - */ -public class ToolSpec { - public static class Builder { - private ToolSpec spec = new ToolSpec(); - - public ToolSpec build() { - return spec; - } - - public Builder name(String name) { - this.spec.setName(name); - return this; - } - - public Builder title(String title) { - this.spec.setTitle(title); - return this; - } - - public Builder description(String description) { - this.spec.setDescription(description); - return this; - } - - public Builder inputSchema(String inputSchema) { - this.spec.setInputSchema(inputSchema); - return this; - } - - public Builder outputSchema(String outputSchema) { - this.spec.setOutputSchema(outputSchema); - return this; - } - - public Builder requiredArguments(List requiredArguments) { - this.spec.setRequiredArguments(requiredArguments); - return this; - } - } - - private String name; - private String title; - private String description; - private String inputSchema; - private String outputSchema; - private List requiredArguments; - private McpSchema.ToolAnnotations annotations; - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getTitle() { - return title; - } - - public void setTitle(String title) { - this.title = title; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - public String getInputSchema() { - return inputSchema; - } - - public void setInputSchema(String inputSchema) { - this.inputSchema = inputSchema; - } - - public String getOutputSchema() { - return outputSchema; - } - - public void setOutputSchema(String outputSchema) { - this.outputSchema = outputSchema; - } - - public List getRequiredArguments() { - return requiredArguments; - } - - public void setRequiredArguments(List requiredArguments) { - this.requiredArguments = requiredArguments; - } - - public McpSchema.ToolAnnotations getAnnotations() { - return annotations; - } - - public void setAnnotations(McpSchema.ToolAnnotations annotations) { - this.annotations = annotations; - } - - public static Builder builder() { - return new Builder(); - } -} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/JoobyMcpServer.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/JoobyMcpServer.java deleted file mode 100644 index 3e9d109208..0000000000 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/JoobyMcpServer.java +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.mcp; - -import java.util.List; -import java.util.Map; - -import io.jooby.Jooby; -import io.jooby.internal.mcp.ToolSpec; -import io.modelcontextprotocol.json.McpJsonMapper; -import io.modelcontextprotocol.server.McpSyncServerExchange; -import io.modelcontextprotocol.spec.McpSchema; - -/** - * @author kliushnichenko - */ -public interface JoobyMcpServer { - - String getServerKey(); - - void init(Jooby app, McpJsonMapper mcpJsonMapper); - - Object invokeTool(String toolName, Map args, McpSyncServerExchange exchange); - - Object invokePrompt(String promptName, Map args, McpSyncServerExchange exchange); - - Object invokeCompletion(String identifier, String argumentName, String input); - - Object readResource(String uri); - - Object readResourceByTemplate(String uri, Map templateArgs); - - Map getTools(); - - Map getPrompts(); - - List getResources(); - - List getResourceTemplates(); - - List getCompletions(); -} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java index 6ff84d175b..b9cbcca7d4 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java @@ -16,10 +16,7 @@ import io.jooby.Extension; import io.jooby.Jooby; import io.jooby.exception.StartupException; -import io.jooby.internal.mcp.BaseMcpServerRunner; import io.jooby.internal.mcp.McpServerConfig; -import io.jooby.internal.mcp.McpStatelessServerRunner; -import io.jooby.internal.mcp.McpSyncServerRunner; import io.jooby.mcp.transport.JoobySseTransportProvider; import io.jooby.mcp.transport.JoobyStatelessServerTransport; import io.jooby.mcp.transport.JoobyStreamableServerTransportProvider; @@ -221,22 +218,6 @@ private static List stat .toList(); } - private BaseMcpServerRunner buildMcpServerRunner( - Jooby app, JoobyMcpServer joobyMcpServer, McpServerConfig serverConfig) { - var isSingleServer = hasSingleMcpServer(); - if (STATELESS_STREAMABLE_HTTP == serverConfig.getTransport()) { - return new McpStatelessServerRunner( - app, joobyMcpServer, serverConfig, mcpJsonMapper, isSingleServer); - } else { - return new McpSyncServerRunner( - app, joobyMcpServer, serverConfig, mcpJsonMapper, isSingleServer); - } - } - - private boolean hasSingleMcpServer() { - return this.mcpServices.size() == 1; - } - private McpServerConfig mcpServerConfig(Jooby application, String key) { var config = application.getConfig(); var mcpPath = MODULE_CONFIG_PREFIX + "." + key; diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/ResourceUri.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/ResourceUri.java deleted file mode 100644 index 13d55a7b8f..0000000000 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/ResourceUri.java +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.mcp; - -/** - * @author kliushnichenko - */ -public record ResourceUri(String uri) { - public static final String CTX_KEY = "__resourceUri"; -} diff --git a/modules/jooby-mcp/src/test/java/io/jooby/mcp/WeatherMcpServer.java b/modules/jooby-mcp/src/test/java/io/jooby/mcp/WeatherMcpServer.java deleted file mode 100644 index 4d82513f45..0000000000 --- a/modules/jooby-mcp/src/test/java/io/jooby/mcp/WeatherMcpServer.java +++ /dev/null @@ -1,177 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.mcp; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.function.Function; -import java.util.function.Supplier; - -import io.jooby.Jooby; -import io.jooby.internal.mcp.MethodInvoker; -import io.jooby.internal.mcp.ToolSpec; -import io.modelcontextprotocol.json.McpJsonMapper; -import io.modelcontextprotocol.server.McpSyncServerExchange; -import io.modelcontextprotocol.spec.McpSchema; - -/** Generated Jooby MCP Server. Do not modify manually. */ -public class WeatherMcpServer implements JoobyMcpServer { - private Jooby app; - - private McpJsonMapper mcpJsonMapper; - - /** Map of tool names to its specification. */ - private final Map tools = new HashMap<>(); - - /** Map of tool names to method invokers. */ - private final Map toolInvokers = new HashMap<>(); - - /** Map of prompt names to its specification. */ - private final Map prompts = new HashMap<>(); - - /** Map of prompt names to method invokers. */ - private final Map promptInvokers = new HashMap<>(); - - /** List of completions reference objects. */ - private final List completions = new ArrayList<>(); - - /** Map of completion key(a composition of _) to method invoker. */ - private final Map> completionInvokers = new HashMap<>(); - - /** List of resources. */ - private final List resources = new ArrayList<>(); - - /** Map of resource URI to method invoker. */ - private final Map> resourceReaders = new HashMap<>(); - - /** List of resource templates. */ - private final List resourceTemplates = new ArrayList<>(); - - /** Map of resource URI template to method invoker. */ - private final Map, Object>> resourceTemplateReaders = - new HashMap<>(); - - /** - * Initialize a new server. - * - * @param app the Jooby application instance - * @param mcpJsonMapper json serializer instance - */ - public void init(final Jooby app, final McpJsonMapper mcpJsonMapper) { - this.app = app; - this.mcpJsonMapper = mcpJsonMapper; - - tools.put( - "get_weather", - ToolSpec.builder() - .name("get_weather") - .inputSchema( - "{\"type\":\"object\",\"properties\":{\"latitude\":{\"type\":\"number\"},\"longitude\":{\"type\":\"number\"}},\"required\":[\"latitude\",\"longitude\"],\"additionalProperties\":false}") - .requiredArguments(List.of("latitude", "longitude")) - .build()); - - toolInvokers.put( - "get_weather", - (args, exchange) -> - app.require(WeatherServer.class) - .getWeather((double) args.get("latitude"), (double) args.get("longitude"))); - } - - /** - * Invokes a tool by name with the provided arguments. - * - * @param toolName the name of the tool to invoke - * @param args the arguments to pass to the tool - * @return the result of the tool invocation - */ - public Object invokeTool( - final String toolName, final Map args, final McpSyncServerExchange exchange) { - MethodInvoker invoker = toolInvokers.get(toolName); - return invoker.invoke(args, exchange); - } - - /** - * Invokes a prompt by name with the provided arguments. - * - * @param promptName the name of the prompt to invoke - * @param args the arguments to pass to the prompt - * @return the result of the prompt invocation - */ - public Object invokePrompt( - final String promptName, - final Map args, - final McpSyncServerExchange exchange) { - MethodInvoker invoker = promptInvokers.get(promptName); - return invoker.invoke(args, exchange); - } - - /** - * Invokes a completion by identifier(prompt or resource name) and argumentName with the provided - * argument value. - * - * @param identifier prompt or resource template name - * @param argumentName the name of an argument in prompt or resource template - * @param input incoming argument value - * @return the result of the completion invocation - */ - public Object invokeCompletion( - final String identifier, final String argumentName, final String input) { - var completionKey = identifier + '_' + argumentName; - var invoker = completionInvokers.get(completionKey); - if (invoker == null) { - return List.of(); - } - return invoker.apply(input); - } - - /** - * Reads a resource by URI - * - * @param uri Resource URI - * @return resource content - */ - public Object readResource(final String uri) { - var reader = resourceReaders.get(uri); - return reader.get(); - } - - /** - * Reads a resource by URI according to template - * - * @param uriTemplate Resource URI template - * @return resource content - */ - public Object readResourceByTemplate(final String uriTemplate, final Map args) { - var reader = resourceTemplateReaders.get(uriTemplate); - return reader.apply(args); - } - - public Map getTools() { - return tools; - } - - public Map getPrompts() { - return prompts; - } - - public List getCompletions() { - return completions; - } - - public List getResources() { - return resources; - } - - public List getResourceTemplates() { - return resourceTemplates; - } - - public String getServerKey() { - return "weather"; - } -} diff --git a/modules/jooby-mcp/src/test/java/io/jooby/mcp/WeatherServer.java b/modules/jooby-mcp/src/test/java/io/jooby/mcp/WeatherServer.java deleted file mode 100644 index 47bf86dc0d..0000000000 --- a/modules/jooby-mcp/src/test/java/io/jooby/mcp/WeatherServer.java +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.mcp; - -public class WeatherServer { - public String getWeather(double latitude, double longitude) { - return ""; - } -} From d68adc469a0af979d59a9cec710e8c1ac09729ef Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Fri, 27 Mar 2026 14:07:47 -0300 Subject: [PATCH 16/37] refactor(mcp): simplify and unify transport class names This commit refactors the transport layer by removing the redundant "Jooby" prefix and "Server" identifiers from the class names, as their context is already clear within the `io.jooby.mcp.transport` package. To provide a consistent and predictable API, all transport implementations now share the `TransportProvider` suffix. Renames: * JoobySseTransportProvider -> SseTransportProvider * JoobyStatelessServerTransport -> StatelessTransportProvider * JoobyStreamableServerTransportProvider -> StreamableTransportProvider * JoobyWebSocketServerTransportProvider -> WebSocketTransportProvider --- .../src/main/java/io/jooby/mcp/McpModule.java | 17 ++++++++--------- ...tProvider.java => SseTransportProvider.java} | 6 +++--- ...ort.java => StatelessTransportProvider.java} | 6 +++--- ...er.java => StreamableTransportProvider.java} | 8 +++----- ...der.java => WebSocketTransportProvider.java} | 7 +++---- 5 files changed, 20 insertions(+), 24 deletions(-) rename modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/{JoobySseTransportProvider.java => SseTransportProvider.java} (97%) rename modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/{JoobyStatelessServerTransport.java => StatelessTransportProvider.java} (95%) rename modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/{JoobyStreamableServerTransportProvider.java => StreamableTransportProvider.java} (98%) rename modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/{JoobyWebSocketServerTransportProvider.java => WebSocketTransportProvider.java} (96%) diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java index b9cbcca7d4..e762d933c7 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java @@ -17,10 +17,10 @@ import io.jooby.Jooby; import io.jooby.exception.StartupException; import io.jooby.internal.mcp.McpServerConfig; -import io.jooby.mcp.transport.JoobySseTransportProvider; -import io.jooby.mcp.transport.JoobyStatelessServerTransport; -import io.jooby.mcp.transport.JoobyStreamableServerTransportProvider; -import io.jooby.mcp.transport.JoobyWebSocketServerTransportProvider; +import io.jooby.mcp.transport.SseTransportProvider; +import io.jooby.mcp.transport.StatelessTransportProvider; +import io.jooby.mcp.transport.StreamableTransportProvider; +import io.jooby.mcp.transport.WebSocketTransportProvider; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapper; @@ -157,7 +157,7 @@ public void install(@NonNull Jooby app) { if (mcpConfig.getTransport() == STATELESS_STREAMABLE_HTTP) { var transport = - new JoobyStatelessServerTransport(app, mcpJsonMapper, mcpConfig, CTX_EXTRACTOR); + new StatelessTransportProvider(app, mcpJsonMapper, mcpConfig, CTX_EXTRACTOR); var statelessServer = McpServer.sync(transport) .serverInfo(mcpConfig.getName(), mcpConfig.getVersion()) @@ -175,15 +175,14 @@ public void install(@NonNull Jooby app) { (switch (mcpConfig.getTransport()) { case STREAMABLE_HTTP -> McpServer.sync( - new JoobyStreamableServerTransportProvider( + new StreamableTransportProvider( app, mcpJsonMapper, mcpConfig, CTX_EXTRACTOR)); case SSE -> McpServer.sync( - new JoobySseTransportProvider( - app, mcpConfig, mcpJsonMapper, CTX_EXTRACTOR)); + new SseTransportProvider(app, mcpConfig, mcpJsonMapper, CTX_EXTRACTOR)); case WEBSOCKET -> McpServer.sync( - new JoobyWebSocketServerTransportProvider( + new WebSocketTransportProvider( app, mcpConfig, mcpJsonMapper, CTX_EXTRACTOR)); default -> throw new IllegalStateException( diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobySseTransportProvider.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/SseTransportProvider.java similarity index 97% rename from modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobySseTransportProvider.java rename to modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/SseTransportProvider.java index a832d3c29f..52be46f889 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobySseTransportProvider.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/SseTransportProvider.java @@ -29,9 +29,9 @@ * connections, message routing, and session management. */ @SuppressWarnings("PMD") -public class JoobySseTransportProvider implements McpServerTransportProvider { +public class SseTransportProvider implements McpServerTransportProvider { - private static final Logger LOG = LoggerFactory.getLogger(JoobySseTransportProvider.class); + private static final Logger LOG = LoggerFactory.getLogger(SseTransportProvider.class); private static final String ENDPOINT_EVENT_TYPE = "endpoint"; private static final String SESSION_ID_KEY = "sessionId"; @@ -51,7 +51,7 @@ public class JoobySseTransportProvider implements McpServerTransportProvider { * @param serverConfig The MCP server configuration containing endpoint settings * @param mcpJsonMapper The MCP JSON mapper for message serialization/deserialization */ - public JoobySseTransportProvider( + public SseTransportProvider( Jooby app, McpServerConfig serverConfig, McpJsonMapper mcpJsonMapper, diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobyStatelessServerTransport.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/StatelessTransportProvider.java similarity index 95% rename from modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobyStatelessServerTransport.java rename to modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/StatelessTransportProvider.java index 227a30c9c8..353532111c 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobyStatelessServerTransport.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/StatelessTransportProvider.java @@ -34,16 +34,16 @@ * @author kliushnichenko */ @SuppressWarnings("PMD") -public class JoobyStatelessServerTransport implements McpStatelessServerTransport { +public class StatelessTransportProvider implements McpStatelessServerTransport { - private static final Logger LOG = LoggerFactory.getLogger(JoobyStatelessServerTransport.class); + private static final Logger LOG = LoggerFactory.getLogger(StatelessTransportProvider.class); private McpStatelessServerHandler mcpHandler; private final McpJsonMapper mcpJsonMapper; private final McpTransportContextExtractor contextExtractor; private volatile boolean isClosing = false; - public JoobyStatelessServerTransport( + public StatelessTransportProvider( Jooby app, McpJsonMapper jsonMapper, McpServerConfig serverConfig, diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobyStreamableServerTransportProvider.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/StreamableTransportProvider.java similarity index 98% rename from modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobyStreamableServerTransportProvider.java rename to modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/StreamableTransportProvider.java index 615e86a855..7ff915e040 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobyStreamableServerTransportProvider.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/StreamableTransportProvider.java @@ -35,11 +35,9 @@ * @author kliushnichenko */ @SuppressWarnings("PMD") -public class JoobyStreamableServerTransportProvider - implements McpStreamableServerTransportProvider { +public class StreamableTransportProvider implements McpStreamableServerTransportProvider { - private static final Logger LOG = - LoggerFactory.getLogger(JoobyStreamableServerTransportProvider.class); + private static final Logger LOG = LoggerFactory.getLogger(StreamableTransportProvider.class); private final boolean disallowDelete; private final McpJsonMapper mcpJsonMapper; @@ -50,7 +48,7 @@ public class JoobyStreamableServerTransportProvider private McpStreamableServerSession.Factory sessionFactory; private KeepAliveScheduler keepAliveScheduler; - public JoobyStreamableServerTransportProvider( + public StreamableTransportProvider( Jooby app, McpJsonMapper jsonMapper, McpServerConfig serverConfig, diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobyWebSocketServerTransportProvider.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/WebSocketTransportProvider.java similarity index 96% rename from modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobyWebSocketServerTransportProvider.java rename to modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/WebSocketTransportProvider.java index 49574e43c8..3443d825cb 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/JoobyWebSocketServerTransportProvider.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/WebSocketTransportProvider.java @@ -34,10 +34,9 @@ * bidirectional client connections, message routing, and session management. */ @SuppressWarnings("PMD") -public class JoobyWebSocketServerTransportProvider implements McpServerTransportProvider { +public class WebSocketTransportProvider implements McpServerTransportProvider { - private static final Logger LOG = - LoggerFactory.getLogger(JoobyWebSocketServerTransportProvider.class); + private static final Logger LOG = LoggerFactory.getLogger(WebSocketTransportProvider.class); private static final String MCP_SESSION_ATTRIBUTE = "mcpSessionId"; private final McpJsonMapper mcpJsonMapper; @@ -55,7 +54,7 @@ public class JoobyWebSocketServerTransportProvider implements McpServerTransport * @param mcpJsonMapper The MCP JSON mapper for message serialization/deserialization * @param contextExtractor The extractor for transport context */ - public JoobyWebSocketServerTransportProvider( + public WebSocketTransportProvider( Jooby app, McpServerConfig serverConfig, McpJsonMapper mcpJsonMapper, From 26c8d52d31f5fce3a8d6b74fb0e8aa5ec38831c1 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Fri, 27 Mar 2026 14:50:51 -0300 Subject: [PATCH 17/37] fix(mcp): resolve SSE truncation and I/O thread blocking in streamable transport This commit fixes a race condition where the Streamable HTTP transport would randomly return an empty payload ("") instead of the tool execution result. The failure occurred because the Jooby I/O thread was being blocked, causing the server to tear down the TCP socket before the final SSE chunk could be flushed to the network. Details: * Replaced `.block()` calls inside `ctx.upgrade()` with non-blocking Reactor `.subscribe()` chains to prevent I/O thread deadlocks. * Refactored `notifyClients`, `closeGracefully`, and the `lastId` replay loop to use native Reactor `Flux` chains instead of blocking Java `.parallelStream().forEach()` loops. * Added a 50ms `Mono.delay` to `JoobyStreamableMcpSessionTransport.closeGracefully()`. This guarantees the underlying server (e.g., Undertow) has a sufficient buffer window to physically flush the final SSE event to the network layer before the connection is destroyed. --- .../mcp/transport/AbstractMcpTransport.java | 34 ++ .../AbstractMcpTransportProvider.java | 90 +++++ .../mcp/transport/SseTransportProvider.java | 117 +------ .../StreamableTransportProvider.java | 308 +++++------------- .../transport/WebSocketTransportProvider.java | 145 ++------- 5 files changed, 253 insertions(+), 441 deletions(-) create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/AbstractMcpTransport.java create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/AbstractMcpTransportProvider.java diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/AbstractMcpTransport.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/AbstractMcpTransport.java new file mode 100644 index 0000000000..b9c75b74c7 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/AbstractMcpTransport.java @@ -0,0 +1,34 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.mcp.transport; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.spec.McpServerTransport; +import reactor.core.publisher.Mono; + +public abstract class AbstractMcpTransport implements McpServerTransport { + protected final Logger log = LoggerFactory.getLogger(getClass()); + + protected final McpJsonMapper mcpJsonMapper; + + public AbstractMcpTransport(McpJsonMapper mcpJsonMapper) { + this.mcpJsonMapper = mcpJsonMapper; + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return mcpJsonMapper.convertValue(data, typeRef); + } + + @Override + public Mono closeGracefully() { + return Mono.fromRunnable(this::close); + } +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/AbstractMcpTransportProvider.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/AbstractMcpTransportProvider.java new file mode 100644 index 0000000000..d468e7c66f --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/AbstractMcpTransportProvider.java @@ -0,0 +1,90 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.mcp.transport; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.jooby.Context; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.server.McpTransportContextExtractor; +import io.modelcontextprotocol.spec.McpServerSession; +import io.modelcontextprotocol.spec.McpServerTransportProvider; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public abstract class AbstractMcpTransportProvider implements McpServerTransportProvider { + + protected final Logger log = LoggerFactory.getLogger(getClass()); + + protected final McpJsonMapper mcpJsonMapper; + protected final McpTransportContextExtractor contextExtractor; + protected final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); + protected final AtomicBoolean isClosing = new AtomicBoolean(false); + protected McpServerSession.Factory sessionFactory; + + public AbstractMcpTransportProvider( + McpJsonMapper mcpJsonMapper, McpTransportContextExtractor contextExtractor) { + this.mcpJsonMapper = mcpJsonMapper; + this.contextExtractor = contextExtractor; + } + + protected abstract String transportName(); + + @Override + public void setSessionFactory(McpServerSession.Factory sessionFactory) { + this.sessionFactory = sessionFactory; + } + + @Override + public Mono notifyClients(String method, Object params) { + if (sessions.isEmpty()) { + log.debug("No active {} sessions to broadcast a message to", transportName()); + return Mono.empty(); + } + + if (log.isDebugEnabled()) { + log.debug( + "Attempting to broadcast to {} active {} sessions", sessions.size(), transportName()); + } + + return Flux.fromIterable(sessions.values()) + .flatMap( + session -> + session + .sendNotification(method, params) + .doOnError( + e -> + log.error( + "Failed to send message to {} session {}: {}", + transportName(), + session.getId(), + e.getMessage())) + .onErrorComplete()) + .then(); + } + + @Override + public Mono closeGracefully() { + return Flux.fromIterable(sessions.values()) + .doFirst( + () -> { + isClosing.set(true); + if (log.isDebugEnabled()) { + log.debug( + "Initiating graceful shutdown for {} {} sessions", + sessions.size(), + transportName()); + } + }) + .flatMap(McpServerSession::closeGracefully) + .doFinally(signalType -> sessions.clear()) + .then(); + } +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/SseTransportProvider.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/SseTransportProvider.java index 52be46f889..af9735e0d0 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/SseTransportProvider.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/SseTransportProvider.java @@ -8,57 +8,28 @@ import static io.jooby.mcp.transport.TransportConstants.*; import java.io.IOException; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicBoolean; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import io.jooby.*; import io.jooby.internal.mcp.McpServerConfig; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonMapper; -import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.server.McpTransportContextExtractor; import io.modelcontextprotocol.spec.*; -import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -/** - * Provides SSE transport implementation for MCP server using Jooby framework. Handles client - * connections, message routing, and session management. - */ -@SuppressWarnings("PMD") -public class SseTransportProvider implements McpServerTransportProvider { - - private static final Logger LOG = LoggerFactory.getLogger(SseTransportProvider.class); +public class SseTransportProvider extends AbstractMcpTransportProvider { private static final String ENDPOINT_EVENT_TYPE = "endpoint"; private static final String SESSION_ID_KEY = "sessionId"; - private final String messageEndpoint; - private final McpJsonMapper mcpJsonMapper; - private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); - private final McpTransportContextExtractor contextExtractor; - - private McpServerSession.Factory sessionFactory; - private final AtomicBoolean isClosing = new AtomicBoolean(false); - - /** - * Constructs a new Jooby Reactive SSE transport provider instance. - * - * @param app The Jooby application instance to register endpoints with - * @param serverConfig The MCP server configuration containing endpoint settings - * @param mcpJsonMapper The MCP JSON mapper for message serialization/deserialization - */ + public SseTransportProvider( Jooby app, McpServerConfig serverConfig, McpJsonMapper mcpJsonMapper, McpTransportContextExtractor contextExtractor) { - this.mcpJsonMapper = mcpJsonMapper; + super(mcpJsonMapper, contextExtractor); this.messageEndpoint = serverConfig.getMessageEndpoint(); - this.contextExtractor = contextExtractor; String sseEndpoint = serverConfig.getSseEndpoint(); app.head(sseEndpoint, ctx -> StatusCode.OK).produces(TEXT_EVENT_STREAM); @@ -67,66 +38,24 @@ public SseTransportProvider( } @Override - public void setSessionFactory(McpServerSession.Factory sessionFactory) { - this.sessionFactory = sessionFactory; - } - - @Override - public Mono notifyClients(String method, Object params) { - if (sessions.isEmpty()) { - LOG.debug("No active sessions to broadcast a message to"); - return Mono.empty(); - } - - if (LOG.isDebugEnabled()) { - LOG.debug("Attempting to broadcast a message to {} active sessions", sessions.size()); - } - - return Flux.fromIterable(sessions.values()) - .flatMap( - session -> - session - .sendNotification(method, params) - .doOnError( - e -> - LOG.error( - "Failed to send message to session {}: {}", - session.getId(), - e.getMessage())) - .onErrorComplete()) - .then(); - } - - @Override - public Mono closeGracefully() { - return Flux.fromIterable(sessions.values()) - .doFirst( - () -> { - isClosing.set(true); - if (LOG.isDebugEnabled()) { - LOG.debug("Initiating graceful shutdown with {} active sessions", sessions.size()); - } - }) - .flatMap(McpServerSession::closeGracefully) - .doFinally(signalType -> sessions.clear()) - .then(); + protected String transportName() { + return "SSE"; } private void handleSseConnection(ServerSentEmitter sse) { - JoobyMcpSessionTransport transport = new JoobyMcpSessionTransport(sse); + JoobyMcpSessionTransport transport = new JoobyMcpSessionTransport(mcpJsonMapper, sse); McpServerSession session = sessionFactory.create(transport); String sessionId = session.getId(); - LOG.debug("New SSE connection has been established. Session ID: {}", sessionId); + log.debug("New SSE connection established. Session ID: {}", sessionId); sessions.put(sessionId, session); sse.onClose( () -> { - LOG.debug("Session with ID {} has been cancelled", sessionId); + log.debug("Session with ID {} has been cancelled", sessionId); sessions.remove(sessionId); }); - LOG.debug("Sending initial endpoint event to session: {}", sessionId); sse.send( new ServerSentMessage(this.messageEndpoint + "?sessionId=" + sessionId) .setEvent(ENDPOINT_EVENT_TYPE)); @@ -143,7 +72,7 @@ private Object handleMessage(Context ctx) { if (ctx.query(SESSION_ID_KEY).isMissing()) { ctx.setResponseCode(StatusCode.BAD_REQUEST); return McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST) - .message("Session ID missing in message endpoint") + .message("Session ID missing") .build(); } @@ -153,7 +82,7 @@ private Object handleMessage(Context ctx) { if (session == null) { ctx.setResponseCode(StatusCode.NOT_FOUND); return McpError.builder(McpSchema.ErrorCodes.RESOURCE_NOT_FOUND) - .message("Session not found: " + sessionId) + .message("Session not found") .build(); } @@ -167,30 +96,28 @@ private Object handleMessage(Context ctx) { .handle(message) .contextWrite( reactorCtx -> - reactorCtx - .put(io.modelcontextprotocol.common.McpTransportContext.KEY, transportContext) - .put("CTX", ctx)) + reactorCtx.put(McpTransportContext.KEY, transportContext).put("CTX", ctx)) .then(Mono.just((Object) StatusCode.OK)) .onErrorResume( error -> { - LOG.error("Error processing message: {}", error.getMessage()); + log.error("Error processing message: {}", error.getMessage()); return Mono.just(StatusCode.OK); }) .switchIfEmpty(Mono.just((Object) StatusCode.OK)) .block(); } catch (IOException | IllegalArgumentException e) { - LOG.error("Failed to deserialize message: {}", e.getMessage()); + log.error("Failed to deserialize message: {}", e.getMessage()); return McpError.builder(McpSchema.ErrorCodes.PARSE_ERROR) .message("Invalid message format") .build(); } } - private class JoobyMcpSessionTransport implements McpServerTransport { - + private static class JoobyMcpSessionTransport extends AbstractMcpTransport { private final ServerSentEmitter sse; - public JoobyMcpSessionTransport(ServerSentEmitter sse) { + public JoobyMcpSessionTransport(McpJsonMapper mcpJsonMapper, ServerSentEmitter sse) { + super(mcpJsonMapper); this.sse = sse; } @@ -202,22 +129,12 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message) { String jsonText = mcpJsonMapper.writeValueAsString(message); sse.send(new ServerSentMessage(jsonText).setEvent(MESSAGE_EVENT_TYPE)); } catch (Exception e) { - LOG.error("Failed to send message: {}", e.getMessage()); + log.error("Failed to send message: {}", e.getMessage()); sse.send(SSE_ERROR_EVENT, e.getMessage()); } }); } - @Override - public T unmarshalFrom(Object data, TypeRef typeRef) { - return mcpJsonMapper.convertValue(data, typeRef); - } - - @Override - public Mono closeGracefully() { - return Mono.fromRunnable(sse::close); - } - @Override public void close() { sse.close(); diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/StreamableTransportProvider.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/StreamableTransportProvider.java index 7ff915e040..ec84df5c36 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/StreamableTransportProvider.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/StreamableTransportProvider.java @@ -28,12 +28,7 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -/** - * Jooby implementation of Streamable HTTP transport. Inspired by WebMvcStreamableServerTransportProvider - * - * @author kliushnichenko - */ +/** Jooby implementation of Streamable HTTP transport. */ @SuppressWarnings("PMD") public class StreamableTransportProvider implements McpStreamableServerTransportProvider { @@ -44,6 +39,7 @@ public class StreamableTransportProvider implements McpStreamableServerTransport private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); private final McpTransportContextExtractor contextExtractor; + private volatile boolean isClosing = false; private McpStreamableServerSession.Factory sessionFactory; private KeepAliveScheduler keepAliveScheduler; @@ -74,38 +70,21 @@ public StreamableTransportProvider( .initialDelay(keepAliveInterval) .interval(keepAliveInterval) .build(); - this.keepAliveScheduler.start(); } } - /** - * Setups the listening SSE connections and message replay. - * - * @param ctx The Jooby context for the incoming request - */ private Context handleGet(Context ctx) { - if (this.isClosing) { - return SendError.serverIsShuttingDown(ctx); - } - - if (!ctx.accept(TEXT_EVENT_STREAM)) { + if (this.isClosing) return SendError.serverIsShuttingDown(ctx); + if (!ctx.accept(TEXT_EVENT_STREAM)) return SendError.invalidAcceptHeader(ctx, List.of(TEXT_EVENT_STREAM)); - } - - var transportContext = this.contextExtractor.extract(ctx); - - if (ctx.header(HttpHeaders.MCP_SESSION_ID).isMissing()) { - return SendError.missingSessionId(ctx); - } + if (ctx.header(HttpHeaders.MCP_SESSION_ID).isMissing()) return SendError.missingSessionId(ctx); String sessionId = ctx.header(HttpHeaders.MCP_SESSION_ID).value(); McpStreamableServerSession session = this.sessions.get(sessionId); + if (session == null) return SendError.sessionNotFound(ctx, sessionId); - if (session == null) { - return SendError.sessionNotFound(ctx, sessionId); - } - + McpTransportContext transportContext = this.contextExtractor.extract(ctx); LOG.debug("Handling GET request for session: {}", sessionId); try { @@ -114,45 +93,33 @@ private Context handleGet(Context ctx) { sse -> { sse.onClose( () -> LOG.debug("SSE connection closed by client for session: {}", sessionId)); + var sessionTransport = new StreamableMcpSessionTransport(sessionId, sse); - var sessionTransport = new JoobyStreamableMcpSessionTransport(sessionId, sse); - - // Check if this is a replay request if (ctx.header(HttpHeaders.LAST_EVENT_ID).isPresent()) { String lastId = ctx.header(HttpHeaders.LAST_EVENT_ID).value(); - try { - session - .replay(lastId) - .contextWrite( - reactorCtx -> - reactorCtx - .put(McpTransportContext.KEY, transportContext) - .put("CTX", ctx)) - .toIterable() - .forEach( - message -> { - try { - sessionTransport - .sendMessage(message) - .contextWrite( - reactorCtx -> - reactorCtx.put(McpTransportContext.KEY, transportContext)) - .block(); - } catch (Exception e) { - LOG.error("Failed to replay message: {}", e.getMessage()); - sse.send(SSE_ERROR_EVENT, e.getMessage()); - } - }); - } catch (Exception e) { - LOG.error("Failed to replay messages: {}", e.getMessage()); - sse.send(SSE_ERROR_EVENT, e.getMessage()); - } + // FIX: Replaced blocking .forEach with non-blocking .concatMap + session + .replay(lastId) + .contextWrite( + reactorCtx -> + reactorCtx.put(McpTransportContext.KEY, transportContext).put("CTX", ctx)) + .concatMap( + message -> + sessionTransport + .sendMessage(message) + .contextWrite( + reactorCtx -> + reactorCtx.put(McpTransportContext.KEY, transportContext))) + .subscribe( + null, + error -> { + LOG.error("Failed to replay messages: {}", error.getMessage()); + sse.send(SSE_ERROR_EVENT, error.getMessage()); + }); } else { - // Establish new listening stream McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = session.listeningStream(sessionTransport); - sse.onClose( () -> { LOG.debug("SSE connection has been closed for session: {}", sessionId); @@ -166,16 +133,8 @@ private Context handleGet(Context ctx) { } } - /** - * Handles POST requests for incoming JSON-RPC messages from clients. - * - * @param ctx The Jooby context for the incoming request - */ private Object handlePost(Context ctx) { - if (this.isClosing) { - return SendError.serverIsShuttingDown(ctx); - } - + if (this.isClosing) return SendError.serverIsShuttingDown(ctx); if (!ctx.accept(TEXT_EVENT_STREAM) || !ctx.accept(MediaType.json)) { return SendError.invalidAcceptHeader(ctx, List.of(TEXT_EVENT_STREAM, MediaType.json)); } @@ -185,16 +144,14 @@ private Object handlePost(Context ctx) { try { var body = ctx.body().valueOrNull(); - if (body == null) { + if (body == null) return SendError.error( ctx, StatusCode.BAD_REQUEST, INVALID_REQUEST, "Request body is missing"); - } + McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(mcpJsonMapper, body); - // Handle initialization request if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest && McpSchema.METHOD_INITIALIZE.equals(jsonrpcRequest.method())) { - McpSchema.InitializeRequest initRequest = mcpJsonMapper.convertValue(jsonrpcRequest.params(), McpSchema.InitializeRequest.class); McpStreamableServerSession.McpStreamableServerSessionInit initObj = @@ -204,7 +161,6 @@ private Object handlePost(Context ctx) { try { McpSchema.InitializeResult initResult = initObj.initResult().block(); - ctx.setResponseHeader(HttpHeaders.MCP_SESSION_ID, sessionId); return new McpSchema.JSONRPCResponse( McpSchema.JSONRPC_VERSION, jsonrpcRequest.id(), initResult, null); @@ -214,17 +170,11 @@ private Object handlePost(Context ctx) { } } - // Handle other messages that require a session - if (ctx.header(HttpHeaders.MCP_SESSION_ID).isMissing()) { + if (ctx.header(HttpHeaders.MCP_SESSION_ID).isMissing()) return SendError.missingSessionId(ctx); - } - sessionId = ctx.header(HttpHeaders.MCP_SESSION_ID).value(); McpStreamableServerSession session = this.sessions.get(sessionId); - - if (session == null) { - return SendError.sessionNotFound(ctx, sessionId); - } + if (session == null) return SendError.sessionNotFound(ctx, sessionId); if (message instanceof McpSchema.JSONRPCResponse jsonrpcResponse) { session @@ -240,28 +190,29 @@ private Object handlePost(Context ctx) { return StatusCode.ACCEPTED; } else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { ctx.setResponseType(TEXT_EVENT_STREAM); - String finalSessionId = sessionId; + return ctx.upgrade( sse -> { sse.onClose( () -> LOG.debug( "Request response stream completed for session: {}", finalSessionId)); - - JoobyStreamableMcpSessionTransport sessionTransport = - new JoobyStreamableMcpSessionTransport(finalSessionId, sse); - - try { - session - .responseStream(jsonrpcRequest, sessionTransport) - .contextWrite( - reactorCtx -> reactorCtx.put(McpTransportContext.KEY, transportContext)) - .block(); - } catch (Exception e) { - LOG.error("Failed to handle request stream: {}", e.getMessage()); - sse.send(SSE_ERROR_EVENT, e.getMessage()); - } + StreamableMcpSessionTransport sessionTransport = + new StreamableMcpSessionTransport(finalSessionId, sse); + + // FIX: Replaced .block() with non-blocking .subscribe() to prevent I/O deadlock + session + .responseStream(jsonrpcRequest, sessionTransport) + .contextWrite( + reactorCtx -> reactorCtx.put(McpTransportContext.KEY, transportContext)) + .subscribe( + null, + error -> { + LOG.error("Failed to handle request stream: {}", error.getMessage()); + sse.send(SSE_ERROR_EVENT, error.getMessage()); + sse.close(); + }); }); } else { return SendError.unknownMsgType(ctx, sessionId); @@ -275,35 +226,17 @@ private Object handlePost(Context ctx) { } } - /** - * Handles DELETE requests for session deletion. - * - * @param ctx The Jooby context for the incoming request - * @return A ServerResponse indicating success or appropriate error status - */ private Object handleDelete(Context ctx) { - if (this.isClosing) { - return SendError.serverIsShuttingDown(ctx); - } - - if (this.disallowDelete) { - return SendError.deletionNotAllowed(ctx); - } - - McpTransportContext transportContext = this.contextExtractor.extract(ctx); - - if (ctx.header(HttpHeaders.MCP_SESSION_ID).isMissing()) { - return SendError.missingSessionId(ctx); - } + if (this.isClosing) return SendError.serverIsShuttingDown(ctx); + if (this.disallowDelete) return SendError.deletionNotAllowed(ctx); + if (ctx.header(HttpHeaders.MCP_SESSION_ID).isMissing()) return SendError.missingSessionId(ctx); String sessionId = ctx.header(HttpHeaders.MCP_SESSION_ID).value(); McpStreamableServerSession session = this.sessions.get(sessionId); - - if (session == null) { - return SendError.sessionNotFound(ctx, sessionId); - } + if (session == null) return SendError.sessionNotFound(ctx, sessionId); try { + McpTransportContext transportContext = this.contextExtractor.extract(ctx); session .delete() .contextWrite(reactorCtx -> reactorCtx.put(McpTransportContext.KEY, transportContext)) @@ -323,111 +256,66 @@ public void setSessionFactory(McpStreamableServerSession.Factory sessionFactory) @Override public Mono notifyClients(String method, Object params) { - if (this.sessions.isEmpty()) { - LOG.debug("No active sessions to broadcast message to"); - return Mono.empty(); - } - - if (LOG.isDebugEnabled()) { - LOG.debug("Attempting to broadcast message to {} active sessions", this.sessions.size()); - } + if (this.sessions.isEmpty()) return Mono.empty(); - return Mono.fromRunnable( - () -> { - this.sessions.values().parallelStream() - .forEach( - session -> { - try { - session.sendNotification(method, params).block(); - } catch (Exception e) { - LOG.error( - "Failed to send message to session {}: {}", - session.getId(), - e.getMessage()); - } - }); - }); + // FIX: Replaced blocking Streams with Reactor Flux + return Flux.fromIterable(this.sessions.values()) + .flatMap( + session -> + session + .sendNotification(method, params) + .doOnError( + e -> + LOG.error( + "Failed to send message to session {}: {}", + session.getId(), + e.getMessage())) + .onErrorComplete()) + .then(); } @Override public Mono closeGracefully() { - return Mono.fromRunnable( - () -> { - this.isClosing = true; - if (LOG.isDebugEnabled()) { - LOG.debug( - "Initiating graceful shutdown with {} active sessions", this.sessions.size()); - } - - this.sessions.values().parallelStream() - .forEach( - session -> { - try { - session.closeGracefully().block(); - } catch (Exception e) { - LOG.error( - "Failed to close session {}: {}", session.getId(), e.getMessage()); - } - }); - + // FIX: Replaced blocking Streams with Reactor Flux + return Flux.fromIterable(sessions.values()) + .doFirst(() -> this.isClosing = true) + .flatMap(McpStreamableServerSession::closeGracefully) + .doFinally( + signalType -> { this.sessions.clear(); - LOG.debug("Graceful shutdown completed"); + if (this.keepAliveScheduler != null) this.keepAliveScheduler.shutdown(); }) - .then() - .doOnSuccess( - v -> { - if (this.keepAliveScheduler != null) { - this.keepAliveScheduler.shutdown(); - } - }); + .then(); } - private class JoobyStreamableMcpSessionTransport implements McpStreamableServerTransport { + private class StreamableMcpSessionTransport implements McpStreamableServerTransport { private final String sessionId; private final ServerSentEmitter sse; private volatile boolean closed = false; - JoobyStreamableMcpSessionTransport(String sessionId, ServerSentEmitter sse) { + StreamableMcpSessionTransport(String sessionId, ServerSentEmitter sse) { this.sessionId = sessionId; this.sse = sse; - LOG.debug("Streamable session transport {} initialized with SSE", sessionId); } - /** - * Sends a JSON-RPC message to the client through the SSE connection. - * - * @param message The JSON-RPC message to send - * @return A Mono that completes when the message has been sent - */ @Override public Mono sendMessage(McpSchema.JSONRPCMessage message) { return sendMessage(message, null); } - /** - * Sends a JSON-RPC message to the client through the SSE connection with a specific message ID. - * - * @param message The JSON-RPC message to send - * @param messageId The message ID for SSE event identification - * @return A Mono that completes when the message has been sent - */ @Override public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId) { return Mono.fromRunnable( () -> { try { - if (this.closed) { - LOG.debug("Session {} was closed during message send attempt", this.sessionId); - return; + if (!closed) { + String jsonText = mcpJsonMapper.writeValueAsString(message); + sse.send( + new ServerSentMessage(jsonText) + .setId(messageId != null ? messageId : this.sessionId) + .setEvent(MESSAGE_EVENT_TYPE)); } - - String jsonText = mcpJsonMapper.writeValueAsString(message); - sse.send( - new ServerSentMessage(jsonText) - .setId(messageId != null ? messageId : this.sessionId) - .setEvent(MESSAGE_EVENT_TYPE)); - LOG.debug("Message sent to session {} with ID {}", this.sessionId, messageId); } catch (Exception e) { LOG.error("Failed to send message to session {}: {}", this.sessionId, e.getMessage()); try { @@ -442,41 +330,25 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId }); } - /** - * Converts data from one type to another using the configured McpJsonMapper. - * - * @param data The source data object to convert - * @param typeRef The target type reference - * @param The target type - * @return The converted object of type T - */ @Override public T unmarshalFrom(Object data, TypeRef typeRef) { return mcpJsonMapper.convertValue(data, typeRef); } - /** - * Initiates a graceful shutdown of the transport. - * - * @return A Mono that completes when the shutdown is complete - */ @Override public Mono closeGracefully() { - return Mono.fromRunnable(this::close); + // FIX: Added a 50ms buffer. This guarantees the underlying server (e.g. Undertow) + // physically flushes the SSE chunk to the network layer before terminating the TCP socket. + return Mono.delay(Duration.ofMillis(50)).then(Mono.fromRunnable(this::close)); } - /** Closes the transport immediately. */ @Override public void close() { try { - if (this.closed) { - LOG.debug("Session transport {} already closed", this.sessionId); - return; + if (!this.closed) { + this.closed = true; + sse.close(); } - - this.closed = true; - sse.close(); - LOG.debug("Successfully closed SSE session {}", sessionId); } catch (Exception e) { LOG.warn("Failed to close SSE session {}: {}", sessionId, e.getMessage()); } diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/WebSocketTransportProvider.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/WebSocketTransportProvider.java index 3443d825cb..dd30c1b26d 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/WebSocketTransportProvider.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/WebSocketTransportProvider.java @@ -6,11 +6,6 @@ package io.jooby.mcp.transport; import java.io.IOException; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicBoolean; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import io.jooby.Context; import io.jooby.Jooby; @@ -20,48 +15,22 @@ import io.jooby.internal.mcp.McpServerConfig; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonMapper; -import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.server.McpTransportContextExtractor; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpServerSession; -import io.modelcontextprotocol.spec.McpServerTransport; -import io.modelcontextprotocol.spec.McpServerTransportProvider; -import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -/** - * Provides WebSocket transport implementation for MCP server using Jooby framework. Handles - * bidirectional client connections, message routing, and session management. - */ @SuppressWarnings("PMD") -public class WebSocketTransportProvider implements McpServerTransportProvider { +public class WebSocketTransportProvider extends AbstractMcpTransportProvider { - private static final Logger LOG = LoggerFactory.getLogger(WebSocketTransportProvider.class); private static final String MCP_SESSION_ATTRIBUTE = "mcpSessionId"; - private final McpJsonMapper mcpJsonMapper; - private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); - private final McpTransportContextExtractor contextExtractor; - - private McpServerSession.Factory sessionFactory; - private final AtomicBoolean isClosing = new AtomicBoolean(false); - - /** - * Constructs a new Jooby WebSocket transport provider instance. - * - * @param app The Jooby application instance to register endpoints with - * @param serverConfig The MCP server configuration containing endpoint settings - * @param mcpJsonMapper The MCP JSON mapper for message serialization/deserialization - * @param contextExtractor The extractor for transport context - */ public WebSocketTransportProvider( Jooby app, McpServerConfig serverConfig, McpJsonMapper mcpJsonMapper, McpTransportContextExtractor contextExtractor) { - this.mcpJsonMapper = mcpJsonMapper; - this.contextExtractor = contextExtractor; - + super(mcpJsonMapper, contextExtractor); String wsEndpoint = serverConfig.getMcpEndpoint(); app.ws( @@ -75,50 +44,8 @@ public WebSocketTransportProvider( } @Override - public void setSessionFactory(McpServerSession.Factory sessionFactory) { - this.sessionFactory = sessionFactory; - } - - @Override - public Mono notifyClients(String method, Object params) { - if (sessions.isEmpty()) { - LOG.debug("No active WebSocket sessions to broadcast a message to"); - return Mono.empty(); - } - - if (LOG.isDebugEnabled()) { - LOG.debug("Attempting to broadcast a message to {} active WS sessions", sessions.size()); - } - - return Flux.fromIterable(sessions.values()) - .flatMap( - session -> - session - .sendNotification(method, params) - .doOnError( - e -> - LOG.error( - "Failed to send message to WS session {}: {}", - session.getId(), - e.getMessage())) - .onErrorComplete()) - .then(); - } - - @Override - public Mono closeGracefully() { - return Flux.fromIterable(sessions.values()) - .doFirst( - () -> { - isClosing.set(true); - if (LOG.isDebugEnabled()) { - LOG.debug( - "Initiating graceful shutdown with {} active WS sessions", sessions.size()); - } - }) - .flatMap(McpServerSession::closeGracefully) - .doFinally(signalType -> sessions.clear()) - .then(); + protected String transportName() { + return "WebSocket"; } private void handleConnect(WebSocket ws) { @@ -127,62 +54,48 @@ private void handleConnect(WebSocket ws) { return; } - JoobyMcpWebSocketTransport transport = new JoobyMcpWebSocketTransport(ws); + JoobyMcpWebSocketTransport transport = new JoobyMcpWebSocketTransport(mcpJsonMapper, ws); McpServerSession session = sessionFactory.create(transport); String sessionId = session.getId(); ws.attribute(MCP_SESSION_ATTRIBUTE, sessionId); sessions.put(sessionId, session); - - LOG.debug("New WebSocket connection established. Session ID: {}", sessionId); + log.debug("New WebSocket connection established. Session ID: {}", sessionId); } private void handleMessage(WebSocket ws, WebSocketMessage msg) { String sessionId = ws.attribute(MCP_SESSION_ATTRIBUTE); - if (sessionId == null) { - LOG.warn("Received message on WebSocket without an associated MCP session"); - return; - } - - McpServerSession session = sessions.get(sessionId); - if (session == null) { - LOG.warn("Received message for unknown WS session ID: {}", sessionId); + if (sessionId == null || !sessions.containsKey(sessionId)) { + log.warn("Received message on unknown or orphaned WS session ID: {}", sessionId); return; } try { Context ctx = ws.getContext(); McpTransportContext transportContext = this.contextExtractor.extract(ctx); - String body = msg.value(); - McpSchema.JSONRPCMessage message = - McpSchema.deserializeJsonRpcMessage(this.mcpJsonMapper, body); + McpSchema.deserializeJsonRpcMessage(this.mcpJsonMapper, msg.value()); - // Unlike HTTP POSTs, WebSockets are fully asynchronous streams, so we just subscribe - // rather than blocking and returning an HTTP StatusCode. - session + sessions + .get(sessionId) .handle(message) .contextWrite( reactorCtx -> - reactorCtx - .put(io.modelcontextprotocol.common.McpTransportContext.KEY, transportContext) - .put("CTX", ctx)) + reactorCtx.put(McpTransportContext.KEY, transportContext).put("CTX", ctx)) .subscribe( null, error -> - LOG.error( - "Error processing WS message for session {}: {}", - sessionId, - error.getMessage())); + log.error( + "Error processing WS message for {}: {}", sessionId, error.getMessage())); } catch (IOException | IllegalArgumentException e) { - LOG.error("Failed to deserialize WS message: {}", e.getMessage()); + log.error("Failed to deserialize WS message: {}", e.getMessage()); } } private void handleClose(WebSocket ws, WebSocketCloseStatus status) { String sessionId = ws.attribute(MCP_SESSION_ATTRIBUTE); if (sessionId != null) { - LOG.debug( + log.debug( "WebSocket connection closed for session: {} with status: {}", sessionId, status.getCode()); @@ -191,16 +104,15 @@ private void handleClose(WebSocket ws, WebSocketCloseStatus status) { } private void handleError(WebSocket ws, Throwable cause) { - String sessionId = ws.attribute(MCP_SESSION_ATTRIBUTE); - LOG.error("WebSocket error for session: {}", sessionId, cause); + log.error("WebSocket error for session: {}", ws.attribute(MCP_SESSION_ATTRIBUTE), cause); } - private class JoobyMcpWebSocketTransport implements McpServerTransport { - + private static class JoobyMcpWebSocketTransport extends AbstractMcpTransport { private final WebSocket ws; private volatile boolean closed = false; - public JoobyMcpWebSocketTransport(WebSocket ws) { + public JoobyMcpWebSocketTransport(McpJsonMapper mcpJsonMapper, WebSocket ws) { + super(mcpJsonMapper); this.ws = ws; } @@ -209,26 +121,13 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message) { return Mono.fromRunnable( () -> { try { - if (!closed) { - String jsonText = mcpJsonMapper.writeValueAsString(message); - ws.send(jsonText); - } + if (!closed) ws.send(mcpJsonMapper.writeValueAsString(message)); } catch (Exception e) { - LOG.error("Failed to send WebSocket message: {}", e.getMessage()); + log.error("Failed to send WebSocket message: {}", e.getMessage()); } }); } - @Override - public T unmarshalFrom(Object data, TypeRef typeRef) { - return mcpJsonMapper.convertValue(data, typeRef); - } - - @Override - public Mono closeGracefully() { - return Mono.fromRunnable(this::close); - } - @Override public void close() { if (!closed) { From a86ee88c4518c9271496ecc47d1e37108c630943 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Fri, 27 Mar 2026 15:16:44 -0300 Subject: [PATCH 18/37] - McpServer.serverKey rename --- .../src/main/java/io/jooby/internal/apt/McpRouter.java | 6 +++--- .../src/test/java/tests/i3830/ExampleServerMcp_.java | 2 +- modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java | 2 +- modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java | 2 +- .../jooby-mcp/src/main/java/io/jooby/mcp/McpService.java | 3 +-- 5 files changed, 7 insertions(+), 8 deletions(-) diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java index 3bbd37638d..01d855965d 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java @@ -175,14 +175,14 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { } buffer.append(statement(indent(4), "}\n")); - // --- serverName() --- + // --- serverKey() --- var serverName = getMcpServerKey(); if (kt) { - buffer.append(statement(indent(4), "override fun serverName(): String? {")); + buffer.append(statement(indent(4), "override fun serverKey(): String {")); buffer.append(statement(indent(6), "return ", string(serverName))); } else { buffer.append(statement(indent(4), "@Override")); - buffer.append(statement(indent(4), "public String serverName() {")); + buffer.append(statement(indent(4), "public String serverKey() {")); buffer.append(statement(indent(6), "return ", string(serverName), semicolon(kt))); } buffer.append(statement(indent(4), "}\n")); diff --git a/modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java index 8c12056f62..733dd793c0 100644 --- a/modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java +++ b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java @@ -41,7 +41,7 @@ public void capabilities( } @Override - public String serverName() { + public String serverKey() { return "example-server"; } diff --git a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java index 22dea2daf5..a86736f288 100644 --- a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java +++ b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java @@ -56,7 +56,7 @@ public void capabilities(io.modelcontextprotocol.spec.McpSchema.ServerCapabiliti } @Override - public String serverName() { + public String serverKey() { return "example-server"; } diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java index e762d933c7..145d3be514 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java @@ -147,7 +147,7 @@ public void install(@NonNull Jooby app) { services.put(McpJsonMapper.class, mcpJsonMapper); var mcpServiceMap = new HashMap>(); for (var mcpService : mcpServices) { - var serverKey = Optional.ofNullable(mcpService.serverName()).orElse("default"); + var serverKey = Optional.ofNullable(mcpService.serverKey()).orElse("default"); mcpServiceMap.computeIfAbsent(serverKey, k -> new ArrayList<>()).add(mcpService); } for (var serverEntry : mcpServiceMap.entrySet()) { diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java index 7a2af765b4..b60d71b1f5 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java @@ -7,7 +7,6 @@ import java.util.List; -import edu.umd.cs.findbugs.annotations.Nullable; import io.jooby.Jooby; import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.McpStatelessServerFeatures; @@ -28,5 +27,5 @@ public interface McpService { List statelessCompletions(); - @Nullable String serverName(); + String serverKey(); } From 8cd4c0a93214fa8d20f043b20ed126c6d6605910 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Fri, 27 Mar 2026 15:35:15 -0300 Subject: [PATCH 19/37] - add more tests over mcp --- .../java/io/jooby/internal/apt/McpRoute.java | 11 +- .../java/io/jooby/i3830/CalculatorTools.java | 9 ++ .../jooby/i3830/McpExchangeInjectionTest.java | 103 ++++++++++++++++++ .../src/test/java/io/jooby/i3830/McpTest.java | 8 -- .../test/java/io/jooby/i3830/UserTools.java | 22 ++++ .../java/io/jooby/i3830/UserToolsTest.java | 72 ++++++++++++ 6 files changed, 210 insertions(+), 15 deletions(-) create mode 100644 tests/src/test/java/io/jooby/i3830/McpExchangeInjectionTest.java delete mode 100644 tests/src/test/java/io/jooby/i3830/McpTest.java create mode 100644 tests/src/test/java/io/jooby/i3830/UserTools.java create mode 100644 tests/src/test/java/io/jooby/i3830/UserToolsTest.java diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java index a3e8769316..4f963435df 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java @@ -542,14 +542,11 @@ public List generateMcpHandlerMethod(boolean kt) { var isNullable = param.isNullable(kt); javaParamNames.add(javaName); - if (type.equals("io.jooby.Context")) { - buffer.add(statement(indent(6), kt ? "val " : "var ", javaName, " = ctx", semicolon(kt))); + if (type.equals("io.jooby.Context") + || type.equals("io.modelcontextprotocol.server.McpSyncServerExchange")) { continue; - } else if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange")) { - buffer.add( - statement(indent(6), kt ? "val " : "var ", javaName, " = exchange", semicolon(kt))); - continue; - } else if (type.equals("io.modelcontextprotocol.common.McpTransportContext")) { + } + if (type.equals("io.modelcontextprotocol.common.McpTransportContext")) { if (kt) { buffer.add( statement( diff --git a/tests/src/test/java/io/jooby/i3830/CalculatorTools.java b/tests/src/test/java/io/jooby/i3830/CalculatorTools.java index c98a0d8350..120b031b0c 100644 --- a/tests/src/test/java/io/jooby/i3830/CalculatorTools.java +++ b/tests/src/test/java/io/jooby/i3830/CalculatorTools.java @@ -6,11 +6,13 @@ package io.jooby.i3830; import java.util.List; +import java.util.Optional; import io.jooby.annotation.McpCompletion; import io.jooby.annotation.McpPrompt; import io.jooby.annotation.McpResource; import io.jooby.annotation.McpTool; +import io.modelcontextprotocol.server.McpSyncServerExchange; /** A collection of tools, prompts, and resources exposed to the LLM via MCP. */ public class CalculatorTools { @@ -52,4 +54,11 @@ public List historyCompletions(String user) { // In a real app, this would query a database for active usernames matching the input return List.of("alice", "bob", "charlie"); } + + @McpTool( + name = "get_session_info", + description = "Returns the current MCP session ID using the injected exchange.") + public String getSessionInfo(McpSyncServerExchange exchange) { + return Optional.ofNullable(exchange.sessionId()).orElse("No active session"); + } } diff --git a/tests/src/test/java/io/jooby/i3830/McpExchangeInjectionTest.java b/tests/src/test/java/io/jooby/i3830/McpExchangeInjectionTest.java new file mode 100644 index 0000000000..0e7cb710d5 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3830/McpExchangeInjectionTest.java @@ -0,0 +1,103 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3830; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.concurrent.atomic.AtomicReference; + +import io.jooby.jackson3.Jackson3Module; +import io.jooby.junit.ServerTest; +import io.jooby.junit.ServerTestRunner; +import io.jooby.mcp.McpModule; + +public class McpExchangeInjectionTest { + + @ServerTest + public void shouldInjectExchangeAndAccessSession(ServerTestRunner runner) throws Exception { + runner + .define( + app -> { + app.install(new Jackson3Module()); + // Register the module using the STREAMABLE_HTTP transport + app.install( + new McpModule(new CalculatorToolsMcp_()) + .transport(McpModule.Transport.STREAMABLE_HTTP)); + }) + .ready( + client -> { + AtomicReference sessionId = new AtomicReference<>(); + + // 1. STREAMABLE_HTTP requires a formal MCP initialize handshake via POST + // to generate the session ID. + String initRequest = + """ + { + "jsonrpc": "2.0", + "id": "init-1", + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "test-client", "version": "1.0.0" } + } + } + """; + + // The transport provider strictly requires these Accept headers + client.header("Accept", "text/event-stream, application/json"); + client.header("Content-Type", "application/json"); + + client.postJson( + "/mcp", + initRequest, + response -> { + assertEquals(200, response.code()); + + // 2. Extract the session ID from the headers + String header = response.header("mcp-session-id"); + assertNotNull( + header, "mcp-session-id header must be present in initialization response"); + sessionId.set(header); + }); + + // 3. Construct the Tool request + String toolRequest = + """ + { + "jsonrpc": "2.0", + "id": "tool-1", + "method": "tools/call", + "params": { + "name": "get_session_info", + "arguments": {} + } + } + """; + + // 4. Send the tool request, appending the session ID we just obtained + client.header("Accept", "text/event-stream, application/json"); + client.header("Content-Type", "application/json"); + client.header("mcp-session-id", sessionId.get()); + + client.postJson( + "/mcp", + toolRequest, + response -> { + assertEquals(200, response.code()); + + var body = response.body().string(); + + assertThat(body) + .contains("\"id\":\"tool-1\"") + .as("The response should contain the calculated result"); + ; + }); + }); + } +} diff --git a/tests/src/test/java/io/jooby/i3830/McpTest.java b/tests/src/test/java/io/jooby/i3830/McpTest.java deleted file mode 100644 index 7dbb4d5b18..0000000000 --- a/tests/src/test/java/io/jooby/i3830/McpTest.java +++ /dev/null @@ -1,8 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.i3830; - -public class McpTest {} diff --git a/tests/src/test/java/io/jooby/i3830/UserTools.java b/tests/src/test/java/io/jooby/i3830/UserTools.java new file mode 100644 index 0000000000..181cc5bb61 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3830/UserTools.java @@ -0,0 +1,22 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3830; + +import io.jooby.annotation.McpTool; + +public class UserTools { + + // The structured output schema we want the LLM to receive + public record UserProfile(String username, String role, boolean active) {} + + @McpTool( + name = "get_user_profile", + description = "Fetches a user profile as a structured JSON object") + public UserProfile getUserProfile(String username) { + // In a real app, this would query a database + return new UserProfile(username, "admin", true); + } +} diff --git a/tests/src/test/java/io/jooby/i3830/UserToolsTest.java b/tests/src/test/java/io/jooby/i3830/UserToolsTest.java new file mode 100644 index 0000000000..9e2eae65c7 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3830/UserToolsTest.java @@ -0,0 +1,72 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3830; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.jooby.jackson3.Jackson3Module; +import io.jooby.junit.ServerTest; +import io.jooby.junit.ServerTestRunner; +import io.jooby.mcp.McpModule; + +public class UserToolsTest { + + @ServerTest + public void shouldReturnStructuredJsonObject(ServerTestRunner runner) { + runner + .define( + app -> { + app.install(new Jackson3Module()); + // Register the tool using the stateless transport + app.install( + new McpModule(new UserToolsMcp_()) + .transport(McpModule.Transport.STATELESS_STREAMABLE_HTTP)); + }) + .ready( + client -> { + // 1. Ask for the structured user profile + String jsonRpcRequest = + """ + { + "jsonrpc": "2.0", + "id": "req-user-1", + "method": "tools/call", + "params": { + "name": "get_user_profile", + "arguments": { + "username": "edgar" + } + } + } + """; + + client.header("Accept", "text/event-stream, application/json"); + client.header("Content-Type", "application/json"); + + client.postJson( + "/mcp", + jsonRpcRequest, + response -> { + assertThat(response.code()).isEqualTo(200); + + String body = response.body().string(); + + // 2. Verify the response ID matches + assertThat(body).containsPattern("\"id\":\\s*\"req-user-1\""); + + // 3. Verify the Java Record was correctly serialized into the tool's text + // content! + assertThat(body) + .contains("\"username\":\"edgar\"") + .contains("\"role\":\"admin\"") + .contains("\"active\":true") + .as( + "The output should be a fully structured JSON payload representing the" + + " Record"); + }); + }); + } +} From 8291207543b5994e9178dc4cc62884e96c55232e Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 28 Mar 2026 12:17:09 -0300 Subject: [PATCH 20/37] feat(mcp): expand @McpResource annotation and enhance APT generation This commit significantly expands the `@McpResource` annotation to fully support the Model Context Protocol (MCP) Resource specification, and updates the annotation processor to map these new fields into the SDK constructors. Details: * Renamed the default `value` attribute to `uri` for better clarity. * Added support for `title`, `description`, `mimeType`, and `size` metadata. * Introduced a nested `@McpAnnotations` interface to support advanced resource metadata, including `audience` roles, `priority`, and `lastModified` dates. * Updated the `McpRoute` APT generator to extract the new attributes and map them correctly into `McpSchema.Resource` and `McpSchema.ResourceTemplate`. * Implemented string-based parsing for nested annotations in `McpRoute` to safely generate the `ResourceAnnotations` object for Java and Kotlin, bypassing the need for heavy `ElementVisitor` boilerplate. --- jooby/src/main/java/io/jooby/MediaType.java | 15 + .../java/io/jooby/annotation/McpResource.java | 46 ++- modules/jooby-apt/pom.xml | 6 + .../java/io/jooby/apt/JoobyProcessor.java | 10 +- .../io/jooby/internal/apt/JsonRpcRoute.java | 4 +- .../java/io/jooby/internal/apt/McpRoute.java | 280 +++++++++++-- .../java/io/jooby/internal/apt/McpRouter.java | 31 +- .../java/io/jooby/internal/apt/RestRoute.java | 5 +- .../java/io/jooby/internal/apt/TrpcRoute.java | 4 +- .../java/io/jooby/internal/apt/WebRoute.java | 18 +- .../java/io/jooby/internal/apt/WebRouter.java | 8 +- .../test/java/tests/i3830/ExampleServer.java | 37 +- .../java/tests/i3830/ExampleServerMcp_.java | 307 -------------- .../src/test/java/tests/i3830/Issue3830.java | 373 +++++++++--------- .../java/io/jooby/javadoc/JavaDocNode.java | 9 + .../java/io/jooby/javadoc/JavaDocParser.java | 26 +- .../java/io/jooby/i3830/CalculatorTools.java | 14 +- 17 files changed, 628 insertions(+), 565 deletions(-) delete mode 100644 modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java diff --git a/jooby/src/main/java/io/jooby/MediaType.java b/jooby/src/main/java/io/jooby/MediaType.java index 460c12858b..6f0b4aaf00 100644 --- a/jooby/src/main/java/io/jooby/MediaType.java +++ b/jooby/src/main/java/io/jooby/MediaType.java @@ -443,6 +443,21 @@ static boolean matches(@NonNull String expected, @NonNull String contentType) { return index > 0 ? byFileExtension(filename.substring(index + 1)) : octetStream; } + /** + * Mediatype by file extension. + * + * @param ext File extension. + * @return Mediatype. + */ + public static @NonNull MediaType byFileExtension( + @NonNull String ext, @NonNull String defaultType) { + var result = byFileExtension(ext); + if (result.equals(octetStream) || result.equals(all)) { + return MediaType.valueOf(defaultType); + } + return result; + } + /** * Mediatype by file extension. * diff --git a/jooby/src/main/java/io/jooby/annotation/McpResource.java b/jooby/src/main/java/io/jooby/annotation/McpResource.java index 48c1a42670..3338431205 100644 --- a/jooby/src/main/java/io/jooby/annotation/McpResource.java +++ b/jooby/src/main/java/io/jooby/annotation/McpResource.java @@ -24,7 +24,7 @@ * * @return The resource URI. */ - String value(); + String uri(); /** * The name of the resource. @@ -33,6 +33,9 @@ */ String name() default ""; + /** Optional human-readable name of the prompt for display purposes. */ + String title() default ""; + /** * A description of the resource. * @@ -41,8 +44,45 @@ String description() default ""; /** - * The MIME type of the resource (e.g., "text/plain", "application/json"). * @return The MIME - * type. + * The MIME type of the resource (e.g., "text/plain", "application/json"). + * + * @return The MIME type. */ String mimeType() default ""; + + /** Optional size in bytes. */ + int size() default -1; + + /** Optional MCP metadata annotations for this resource. */ + McpAnnotations[] + annotations() default {}; // Using an array is the safest way to provide an "empty" default in + + // Java annotations + + enum Role { + USER, + ASSISTANT + } + + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.ANNOTATION_TYPE) + @interface McpAnnotations { + + /** + * Describes who the intended customer of this object or data is. It can include multiple + * entries to indicate content useful for multiple audiences (e.g., [“user”, “assistant”]). + */ + Role[] audience(); + + /** The date and time (in ISO 8601 format) when the resource was last modified. */ + String lastModified() default ""; + + /** + * Describes how important this data is for operating the server. + * + *

A value of 1 means “most important,” and indicates that the data is effectively required, + * while 0 means “least important,” and indicates that the data is entirely optional. + */ + double priority(); + } } diff --git a/modules/jooby-apt/pom.xml b/modules/jooby-apt/pom.xml index 47ede91594..3346cff7f0 100644 --- a/modules/jooby-apt/pom.xml +++ b/modules/jooby-apt/pom.xml @@ -18,6 +18,12 @@ + + io.jooby + jooby-javadoc + ${jooby.version} + + io.jooby jooby diff --git a/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java b/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java index ee649affab..6a8779db60 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java +++ b/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java @@ -8,7 +8,7 @@ import static io.jooby.apt.JoobyProcessor.Options.*; import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.Optional.ofNullable; -import static javax.tools.StandardLocation.SOURCE_OUTPUT; +import static javax.tools.StandardLocation.*; import java.io.*; import java.net.URI; @@ -21,9 +21,7 @@ import javax.annotation.processing.*; import javax.lang.model.SourceVersion; import javax.lang.model.element.*; -import javax.tools.Diagnostic; -import javax.tools.JavaFileObject; -import javax.tools.SimpleJavaFileObject; +import javax.tools.*; import io.jooby.internal.apt.*; @@ -138,10 +136,6 @@ public boolean process(Set annotations, RoundEnvironment for (var controller : controllers) { if (controller.getModifiers().contains(Modifier.ABSTRACT)) continue; - // These factory methods will scan the class methods and return a populated router - // if it finds relevant annotations (@GET for Rest, @McpTool for MCP, etc.) - // We will implement these factories inside the respective Router classes. - var restRouter = RestRouter.parse(context, controller); if (!restRouter.isEmpty()) { activeRouters.add(restRouter); diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRoute.java index fce41d9730..d84288baaf 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRoute.java @@ -15,9 +15,9 @@ import javax.lang.model.element.ExecutableElement; -public class JsonRpcRoute extends WebRoute { +public class JsonRpcRoute extends WebRoute { - public JsonRpcRoute(WebRouter router, ExecutableElement method) { + public JsonRpcRoute(JsonRpcRouter router, ExecutableElement method) { super(router, method); } diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java index 4f963435df..75f201363d 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java @@ -6,20 +6,23 @@ package io.jooby.internal.apt; import static io.jooby.internal.apt.CodeBlock.*; +import static io.jooby.internal.apt.CodeBlock.string; import java.util.ArrayList; import java.util.List; import javax.lang.model.element.ExecutableElement; -public class McpRoute extends WebRoute { +import io.jooby.javadoc.JavaDocNode; + +public class McpRoute extends WebRoute { private boolean isMcpTool = false; private boolean isMcpPrompt = false; private boolean isMcpResource = false; private boolean isMcpResourceTemplate = false; private boolean isMcpCompletion = false; - public McpRoute(WebRouter router, ExecutableElement method) { + public McpRoute(McpRouter router, ExecutableElement method) { super(router, method); checkMcpAnnotations(); } @@ -42,7 +45,7 @@ private void checkMcpAnnotations() { AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.McpResource"); if (resourceAnno != null) { String uri = - AnnotationSupport.findAnnotationValue(resourceAnno, "value"::equals).stream() + AnnotationSupport.findAnnotationValue(resourceAnno, "uri"::equals).stream() .findFirst() .orElse(""); if (uri.contains("{") && uri.contains("}")) { @@ -83,11 +86,19 @@ private String extractAnnotationValue(String annotationName, String attribute) { public List generateMcpDefinitionMethod(boolean kt) { List buffer = new ArrayList<>(); - + var method = router.getMethodDoc(getMethodName(), getRawParameterTypes(false, kt, true)); + var methodSummary = method.map(JavaDocNode::getSummary).orElse(""); + var methodDescription = method.map(JavaDocNode::getDescription).orElse(""); + var methodSummaryAndDescription = method.map(JavaDocNode::getFullDescription).orElse(""); if (isMcpTool()) { String toolName = extractAnnotationValue("io.jooby.annotation.McpTool", "name"); - if (toolName.isEmpty()) toolName = getMethodName(); + if (toolName.isEmpty()) { + toolName = getMethodName(); + } String description = extractAnnotationValue("io.jooby.annotation.McpTool", "description"); + if (description.isEmpty()) { + description = methodSummaryAndDescription; + } if (kt) { buffer.add( @@ -141,31 +152,106 @@ public List generateMcpDefinitionMethod(boolean kt) { || type.equals("io.modelcontextprotocol.common.McpTransportContext") || type.equals("io.jooby.Context")) continue; - String mcpName = param.getMcpName(); + var mcpName = param.getMcpName(); + var javaName = param.getName(); + + // 1. Extract the description from the @McpParam annotation + var paramDescription = ""; + var varEl = + this.method.getParameters().stream() + .filter(p -> p.getSimpleName().toString().equals(javaName)) + .findFirst() + .orElse(null); + + if (varEl != null) { + var paramAnno = + AnnotationSupport.findAnnotationByName(varEl, "io.jooby.annotation.McpParam"); + if (paramAnno != null) { + paramDescription = + AnnotationSupport.findAnnotationValue(paramAnno, "description"::equals).stream() + .findFirst() + .map(v -> v.replace("\"", "")) + .orElse(""); + } + } + if (paramDescription.isEmpty()) { + paramDescription = method.map(it -> it.getParameterDoc(param.getName())).orElse(""); + } + // 2. Generate the schema and inject the description directly if (kt) { + buffer.add( + statement( + indent(6), + "val schema_", + mcpName, + " = schemaGenerator.generateSchema(", + type, + "::class.java)")); + + if (!paramDescription.isEmpty()) { + buffer.add( + statement( + indent(6), + "schema_", + mcpName, + ".put(", + string("description"), + ", ", + string(paramDescription), + ")")); + } + buffer.add( statement( indent(6), "props.set(", string(mcpName), - ", schemaGenerator.generateSchema(", - type, - "::class.java))")); - if (!param.isNullable(kt)) + ", schema_", + mcpName, + ")")); + + if (!param.isNullable(kt)) { buffer.add(statement(indent(6), "req.add(", string(mcpName), ")")); + } } else { + buffer.add( + statement( + indent(6), + "var schema_", + mcpName, + " = schemaGenerator.generateSchema(", + type, + ".class)", + semicolon(kt))); + + if (!paramDescription.isEmpty()) { + buffer.add( + statement( + indent(6), + "schema_", + mcpName, + ".put(", + string("description"), + ", ", + string(paramDescription), + ")", + semicolon(kt))); + } + buffer.add( statement( indent(6), "props.set(", string(mcpName), - ", schemaGenerator.generateSchema(", - type, - ".class))", + ", schema_", + mcpName, + ")", semicolon(kt))); - if (!param.isNullable(kt)) + + if (!param.isNullable(kt)) { buffer.add(statement(indent(6), "req.add(", string(mcpName), ")", semicolon(kt))); + } } } @@ -244,8 +330,13 @@ public List generateMcpDefinitionMethod(boolean kt) { } else if (isMcpPrompt()) { String promptName = extractAnnotationValue("io.jooby.annotation.McpPrompt", "name"); - if (promptName.isEmpty()) promptName = getMethodName(); + if (promptName.isEmpty()) { + promptName = getMethodName(); + } String description = extractAnnotationValue("io.jooby.annotation.McpPrompt", "description"); + if (description.isEmpty()) { + description = methodSummaryAndDescription; + } if (kt) { buffer.add( @@ -328,10 +419,46 @@ public List generateMcpDefinitionMethod(boolean kt) { buffer.add(statement(indent(4), "}\n")); } else if (isMcpResource() || isMcpResourceTemplate()) { - var uri = extractAnnotationValue("io.jooby.annotation.McpResource", "value"); + var uri = extractAnnotationValue("io.jooby.annotation.McpResource", "uri"); var name = extractAnnotationValue("io.jooby.annotation.McpResource", "name"); - if (name.isEmpty()) name = getMethodName(); + if (name.isEmpty()) { + name = getMethodName(); + } + + var title = extractAnnotationValue("io.jooby.annotation.McpResource", "title"); var description = extractAnnotationValue("io.jooby.annotation.McpResource", "description"); + var mimeType = extractAnnotationValue("io.jooby.annotation.McpResource", "mimeType"); + var sizeStr = extractAnnotationValue("io.jooby.annotation.McpResource", "size"); + + // Prepare standard arguments safely + var titleArg = + title.isEmpty() + ? (methodSummary.isEmpty() ? "null" : string(methodSummary)) + : string(title); + var descriptionArg = + description.isEmpty() + ? (methodDescription.isEmpty() ? "null" : string(methodDescription)) + : string(description); + var mimeTypeArg = + mimeType.isEmpty() + ? of( + "io.jooby.MediaType.byFileExtension(", + string(uri), + ", ", + string("text/plain"), + ").getValue()") + : string(mimeType); + String sizeArg = (sizeStr.isEmpty() || sizeStr.equals("-1")) ? "null" : sizeStr + "L"; + + // --- NESTED ANNOTATION EXTRACTION --- + // We parse the string representation of the annotation to avoid massive APT ElementVisitor + // boilerplate. + // It looks like: @...McpAnnotations(audience={"USER"}, priority=1.0, lastModified="2024") + String annotationsArg = "null"; + String rawAnnotations = + extractAnnotationValue("io.jooby.annotation.McpResource", "annotations"); + + boolean hasAnnotations = rawAnnotations.contains("priority="); var isTemplate = isMcpResourceTemplate(); var specType = isTemplate ? "ResourceTemplate" : "Resource"; @@ -346,6 +473,37 @@ public List generateMcpDefinitionMethod(boolean kt) { "Spec(): io.modelcontextprotocol.spec.McpSchema.", specType, " {")); + + // Build the Kotlin ResourceAnnotations object if present + if (hasAnnotations) { + annotationsArg = "annotations"; + String audienceList = + rawAnnotations.contains("USER") && rawAnnotations.contains("ASSISTANT") + ? "listOf(io.modelcontextprotocol.spec.McpSchema.Role.USER," + + " io.modelcontextprotocol.spec.McpSchema.Role.ASSISTANT)" + : (rawAnnotations.contains("USER") + ? "listOf(io.modelcontextprotocol.spec.McpSchema.Role.USER)" + : (rawAnnotations.contains("ASSISTANT") + ? "listOf(io.modelcontextprotocol.spec.McpSchema.Role.ASSISTANT)" + : "emptyList()")); + + String priority = rawAnnotations.replaceAll(".*priority=([0-9.]+).*", "$1"); + var lastMod = + rawAnnotations.contains("lastModified=") + ? string(rawAnnotations.replaceAll(".*lastModified=\"([^\"]+)\".*", "$1")) + : "null"; + + buffer.add(statement(indent(6), "val audience = ", audienceList)); + buffer.add( + statement( + indent(6), + "val annotations = io.modelcontextprotocol.spec.McpSchema.Annotations(audience, ", + priority, + ", ", + lastMod, + ")")); + } + if (!isTemplate) { buffer.add( statement( @@ -354,9 +512,17 @@ public List generateMcpDefinitionMethod(boolean kt) { string(uri), ", ", string(name), - ", null, ", - string(description), - ", null, null, null, null)")); + ", ", + titleArg, + ", ", + descriptionArg, + ", ", + mimeTypeArg, + ", ", + sizeArg, + ", ", + annotationsArg, + ", null)")); } else { buffer.add( statement( @@ -365,11 +531,18 @@ public List generateMcpDefinitionMethod(boolean kt) { string(uri), ", ", string(name), - ", null, ", - string(description), - ", null, null, null)")); + ", ", + titleArg, + ", ", + descriptionArg, + ", ", + mimeTypeArg, + ", ", + annotationsArg, + ", null)")); } buffer.add(statement(indent(4), "}\n")); + } else { buffer.add( statement( @@ -380,6 +553,39 @@ public List generateMcpDefinitionMethod(boolean kt) { getMethodName(), specType, "Spec() {")); + + // Build the Java ResourceAnnotations object if present + if (hasAnnotations) { + annotationsArg = "annotations"; + String audienceList = + rawAnnotations.contains("USER") && rawAnnotations.contains("ASSISTANT") + ? "java.util.List.of(io.modelcontextprotocol.spec.McpSchema.Role.USER," + + " io.modelcontextprotocol.spec.McpSchema.Role.ASSISTANT)" + : (rawAnnotations.contains("USER") + ? "java.util.List.of(io.modelcontextprotocol.spec.McpSchema.Role.USER)" + : (rawAnnotations.contains("ASSISTANT") + ? "java.util.List.of(io.modelcontextprotocol.spec.McpSchema.Role.ASSISTANT)" + : "java.util.Collections.emptyList()")); + + String priority = rawAnnotations.replaceAll(".*priority=([0-9.]+).*", "$1"); + var lastMod = + rawAnnotations.contains("lastModified=") + ? string(rawAnnotations.replaceAll(".*lastModified=\"([^\"]+)\".*", "$1")) + : "null"; + + buffer.add(statement(indent(6), "var audience = ", audienceList, semicolon(kt))); + buffer.add( + statement( + indent(6), + "var annotations = new" + + " io.modelcontextprotocol.spec.McpSchema.Annotations(audience, ", + priority, + "D, ", + lastMod, + ")", + semicolon(kt))); + } + if (!isTemplate) { buffer.add( statement( @@ -388,9 +594,17 @@ public List generateMcpDefinitionMethod(boolean kt) { string(uri), ", ", string(name), - ", null, ", - string(description), - ", null, null, null, null)", + ", ", + titleArg, + ", ", + descriptionArg, + ", ", + mimeTypeArg, + ", ", + sizeArg, + ", ", + annotationsArg, + ", null)", semicolon(kt))); } else { buffer.add( @@ -400,9 +614,15 @@ public List generateMcpDefinitionMethod(boolean kt) { string(uri), ", ", string(name), - ", null, ", - string(description), - ", null, null, null)", + ", ", + titleArg, + ", ", + descriptionArg, + ", ", + mimeTypeArg, + ", ", + annotationsArg, + ", null)", semicolon(kt))); } buffer.add(statement(indent(4), "}\n")); @@ -488,7 +708,7 @@ public List generateMcpHandlerMethod(boolean kt) { semicolon(kt))); } } else if (isMcpResource() || isMcpResourceTemplate()) { - String uriTemplate = extractAnnotationValue("io.jooby.annotation.McpResource", "value"); + String uriTemplate = extractAnnotationValue("io.jooby.annotation.McpResource", "uri"); boolean isTemplate = isMcpResourceTemplate(); if (isTemplate) { diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java index 01d855965d..44e40ff372 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java @@ -9,20 +9,35 @@ import static io.jooby.internal.apt.CodeBlock.*; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; import java.util.ArrayList; +import java.util.List; +import java.util.Optional; import javax.lang.model.element.ElementKind; import javax.lang.model.element.ExecutableElement; import javax.lang.model.element.TypeElement; +import io.jooby.javadoc.JavaDocParser; +import io.jooby.javadoc.MethodDoc; + public class McpRouter extends WebRouter { + private final JavaDocParser javadoc; + public McpRouter(MvcContext context, TypeElement clazz) { super(context, clazz); + var src = Paths.get("").toAbsolutePath(); + if (!src.endsWith("src") && Files.exists(src.resolve("src"))) { + src = src.resolve("src"); + } + this.javadoc = new JavaDocParser(src); } public static McpRouter parse(MvcContext context, TypeElement controller) { var router = new McpRouter(context, controller); + for (var enclosed : controller.getEnclosedElements()) { if (enclosed.getKind() == ElementKind.METHOD) { var route = new McpRoute(router, (ExecutableElement) enclosed); @@ -83,7 +98,7 @@ private String findTargetMethodName(String ref) { route.getMethod(), "io.jooby.annotation.McpResource"); var uri = annotation != null - ? AnnotationSupport.findAnnotationValue(annotation, "value"::equals).stream() + ? AnnotationSupport.findAnnotationValue(annotation, "uri"::equals).stream() .findFirst() .orElse("") : ""; @@ -163,13 +178,15 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { + " capabilities(io.modelcontextprotocol.spec.McpSchema.ServerCapabilities.Builder" + " capabilities) {")); } - if (!tools.isEmpty()) + if (!tools.isEmpty()) { buffer.append(statement(indent(6), "capabilities.tools(true)", semicolon(kt))); - if (!prompts.isEmpty()) + } + if (!prompts.isEmpty()) { buffer.append(statement(indent(6), "capabilities.prompts(true)", semicolon(kt))); - if (!resources.isEmpty()) + } + if (!resources.isEmpty()) { buffer.append(statement(indent(6), "capabilities.resources(true, true)", semicolon(kt))); - // ADD THIS BLOCK: + } if (!completionGroups.isEmpty()) { buffer.append(statement(indent(6), "capabilities.completions()", semicolon(kt))); } @@ -661,4 +678,8 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { .replace("${constructors}", constructors(mcpClassName, kt)) .replace("${methods}", trimr(buffer)); } + + public Optional getMethodDoc(String methodName, List types) { + return javadoc.parse(getTargetType().toString()).flatMap(it -> it.getMethod(methodName, types)); + } } diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java index 902af5abad..7f4f09b500 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java @@ -24,12 +24,11 @@ import javax.lang.model.element.TypeElement; import javax.lang.model.type.TypeKind; -public class RestRoute extends WebRoute { +public class RestRoute extends WebRoute { private final TypeElement httpMethodAnnotation; private String generatedName; - public RestRoute( - WebRouter router, ExecutableElement method, TypeElement httpMethodAnnotation) { + public RestRoute(RestRouter router, ExecutableElement method, TypeElement httpMethodAnnotation) { super(router, method); this.httpMethodAnnotation = httpMethodAnnotation; this.generatedName = method.getSimpleName().toString(); diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRoute.java index 57697c57d2..5c21b564bb 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRoute.java @@ -15,11 +15,11 @@ import javax.lang.model.element.ExecutableElement; import javax.lang.model.type.TypeKind; -public class TrpcRoute extends WebRoute { +public class TrpcRoute extends WebRoute { private final HttpMethod resolvedTrpcMethod; private String generatedName; - public TrpcRoute(WebRouter router, ExecutableElement method) { + public TrpcRoute(TrpcRouter router, ExecutableElement method) { super(router, method); this.resolvedTrpcMethod = discoverTrpcMethod(); this.generatedName = method.getSimpleName().toString(); diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRoute.java index de20026e58..fcd3380415 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRoute.java @@ -18,17 +18,17 @@ import javax.lang.model.type.TypeMirror; import javax.lang.model.type.WildcardType; -public abstract class WebRoute { +public abstract class WebRoute> { protected final MvcContext context; protected final ExecutableElement method; protected final List parameters; protected final TypeDefinition returnType; protected final boolean suspendFun; protected final boolean hasBeanValidation; - protected final WebRouter router; + protected final R router; private boolean uncheckedCast; - public WebRoute(WebRouter router, ExecutableElement method) { + public WebRoute(R router, ExecutableElement method) { this.context = router.context; this.router = router; this.method = method; @@ -43,7 +43,7 @@ public WebRoute(WebRouter router, ExecutableElement method) { context.getProcessingEnvironment().getTypeUtils(), method.getReturnType()); } - public WebRouter getRouter() { + public R getRouter() { return router; } @@ -114,6 +114,16 @@ public List getRawParameterTypes(boolean skipCoroutine, boolean kt) { .toList(); } + public List getRawParameterTypes( + boolean skipCoroutine, boolean kt, boolean keepJavaLang) { + return getParameters(skipCoroutine).stream() + .map(MvcParameter::getType) + .map(TypeDefinition::getRawType) + .map(TypeMirror::toString) + .map(it -> keepJavaLang ? it : type(kt, it)) + .toList(); + } + /** * Returns the return type of the route method. Used to determine if the route returns a reactive * type that requires static imports. diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRouter.java index c220fd1ce9..411c0a3981 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRouter.java @@ -32,19 +32,19 @@ public class ${generatedClassName} implements ${implements} { protected java.util.function.Function factory; ${constructors} public ${generatedClassName}(${className} instance) { - setup(ctx -> instance); + setup(ctx -> instance); } public ${generatedClassName}(io.jooby.SneakyThrows.Supplier<${className}> provider) { - setup(ctx -> (${className}) provider.get()); + setup(ctx -> (${className}) provider.get()); } public ${generatedClassName}(io.jooby.SneakyThrows.Function, ${className}> provider) { - setup(ctx -> provider.apply(${className}.class)); + setup(ctx -> provider.apply(${className}.class)); } private void setup(java.util.function.Function factory) { - this.factory = factory; + this.factory = factory; } ${methods} diff --git a/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java index 681ceeb5bb..a452e8a625 100644 --- a/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java +++ b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java @@ -13,13 +13,19 @@ @McpServer("example-server") public class ExampleServer { - // 1. Tool - @McpTool(name = "calculator", description = "A simple calculator") - public int add(@McpParam(name = "a") int a, @McpParam(name = "b") int b) { + /** + * Add two numbers. A simple calculator. + * + * @param a 1st number + * @return sum of the two numbers + */ + @McpTool(name = "calculator") + public int add(@McpParam(name = "a") int a, @McpParam(description = "2nd number") int b) { return a + b; } // 2. Prompt + /** * Reviews the given code snippet in the context of the specified programming language. * @@ -33,15 +39,30 @@ public String reviewCode( return "Please review this " + language + " code:\n" + code; } - // 3. Static Resource - @McpResource("file:///logs/app.log") + /** Logs Title. Log description Suspendisse potenti. */ + @McpResource( + uri = "file:///logs/app.log", + name = "Application Logs", + size = 1024, + annotations = { + @McpResource.McpAnnotations( + audience = McpResource.Role.USER, + lastModified = "1", + priority = 1.5) + }) public String getLogs() { return "Log content here..."; } - // 4. Resource Template - @McpResource("file:///users/{id}/{name}/profile") - public Map getUserProfile(String id) { + /** + * Resource Template. + * + * @param id User ID. + * @param name User name. + * @return User profile. + */ + @McpResource(uri = "file:///users/{id}/{name}/profile", mimeType = "application/json") + public Map getUserProfile(String id, String name) { return Map.of("id", id, "name", "John Doe"); } diff --git a/modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java deleted file mode 100644 index 733dd793c0..0000000000 --- a/modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java +++ /dev/null @@ -1,307 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package tests.i3830; - -@io.jooby.annotation.Generated(ExampleServer.class) -public class ExampleServerMcp_ implements io.jooby.mcp.McpService { - protected java.util.function.Function factory; - - public ExampleServerMcp_() { - this(io.jooby.SneakyThrows.singleton(ExampleServer::new)); - } - - public ExampleServerMcp_(ExampleServer instance) { - setup(ctx -> instance); - } - - public ExampleServerMcp_(io.jooby.SneakyThrows.Supplier provider) { - setup(ctx -> (ExampleServer) provider.get()); - } - - public ExampleServerMcp_( - io.jooby.SneakyThrows.Function, ExampleServer> provider) { - setup(ctx -> provider.apply(ExampleServer.class)); - } - - private void setup(java.util.function.Function factory) { - this.factory = factory; - } - - private io.modelcontextprotocol.json.McpJsonMapper json; - - @Override - public void capabilities( - io.modelcontextprotocol.spec.McpSchema.ServerCapabilities.Builder capabilities) { - capabilities.tools(true); - capabilities.prompts(true); - capabilities.resources(true, true); - } - - @Override - public String serverKey() { - return "example-server"; - } - - @Override - public java.util.List< - io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification> - completions() { - var completions = - new java.util.ArrayList< - io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification>(); - completions.add( - new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification( - new io.modelcontextprotocol.spec.McpSchema.ResourceReference( - "file:///users/{id}/{name}/profile"), - (exchange, req) -> this.getUserProfileCompletionHandler(exchange, null, req))); - completions.add( - new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification( - new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), - (exchange, req) -> this.reviewCodeCompletionHandler(exchange, null, req))); - return completions; - } - - @Override - public java.util.List< - io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification> - statelessCompletions() { - var completions = - new java.util.ArrayList< - io.modelcontextprotocol.server.McpStatelessServerFeatures - .SyncCompletionSpecification>(); - completions.add( - new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification( - new io.modelcontextprotocol.spec.McpSchema.ResourceReference( - "file:///users/{id}/{name}/profile"), - (ctx, req) -> this.getUserProfileCompletionHandler(null, ctx, req))); - completions.add( - new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification( - new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), - (ctx, req) -> this.reviewCodeCompletionHandler(null, ctx, req))); - return completions; - } - - @Override - public void install(io.jooby.Jooby app, io.modelcontextprotocol.server.McpSyncServer server) - throws Exception { - this.json = app.getServices().require(io.modelcontextprotocol.json.McpJsonMapper.class); - var mapper = app.require(tools.jackson.databind.ObjectMapper.class); - var configBuilder = - new com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder( - com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12, - com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON); - var schemaGenerator = - new com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build()); - - server.addTool( - new io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification( - addToolSpec(mapper, schemaGenerator), - (exchange, req) -> this.add(exchange, null, req))); - server.addPrompt( - new io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification( - reviewCodePromptSpec(), (exchange, req) -> this.reviewCode(exchange, null, req))); - server.addResource( - new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification( - getLogsResourceSpec(), (exchange, req) -> this.getLogs(exchange, null, req))); - server.addResourceTemplate( - new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceTemplateSpecification( - getUserProfileResourceTemplateSpec(), - (exchange, req) -> this.getUserProfile(exchange, null, req))); - } - - @Override - public void install( - io.jooby.Jooby app, io.modelcontextprotocol.server.McpStatelessSyncServer server) - throws Exception { - this.json = app.getServices().require(io.modelcontextprotocol.json.McpJsonMapper.class); - var mapper = app.require(tools.jackson.databind.ObjectMapper.class); - var configBuilder = - new com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder( - com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12, - com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON); - var schemaGenerator = - new com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build()); - - server.addTool( - new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncToolSpecification( - addToolSpec(mapper, schemaGenerator), (ctx, req) -> this.add(null, ctx, req))); - server.addPrompt( - new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncPromptSpecification( - reviewCodePromptSpec(), (ctx, req) -> this.reviewCode(null, ctx, req))); - server.addResource( - new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceSpecification( - getLogsResourceSpec(), (ctx, req) -> this.getLogs(null, ctx, req))); - server.addResourceTemplate( - new io.modelcontextprotocol.server.McpStatelessServerFeatures - .SyncResourceTemplateSpecification( - getUserProfileResourceTemplateSpec(), - (ctx, req) -> this.getUserProfile(null, ctx, req))); - } - - private io.modelcontextprotocol.spec.McpSchema.Tool addToolSpec( - tools.jackson.databind.ObjectMapper mapper, - com.github.victools.jsonschema.generator.SchemaGenerator schemaGenerator) { - var schema = mapper.createObjectNode(); - schema.put("type", "object"); - var props = schema.putObject("properties"); - var req = schema.putArray("required"); - props.set("a", schemaGenerator.generateSchema(int.class)); - req.add("a"); - props.set("b", schemaGenerator.generateSchema(int.class)); - req.add("b"); - return new io.modelcontextprotocol.spec.McpSchema.Tool( - "calculator", - null, - "A simple calculator", - mapper.treeToValue(schema, io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), - null, - null, - null); - } - - private io.modelcontextprotocol.spec.McpSchema.CallToolResult add( - io.modelcontextprotocol.server.McpSyncServerExchange exchange, - io.modelcontextprotocol.common.McpTransportContext transportContext, - io.modelcontextprotocol.spec.McpSchema.CallToolRequest req) { - var ctx = - exchange != null - ? (io.jooby.Context) exchange.transportContext().get("CTX") - : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); - var args = - req.arguments() != null - ? req.arguments() - : java.util.Collections.emptyMap(); - var c = this.factory.apply(ctx); - var raw_a = args.get("a"); - if (raw_a == null) throw new IllegalArgumentException("Missing req param: a"); - var a = - raw_a instanceof Number ? ((Number) raw_a).intValue() : Integer.parseInt(raw_a.toString()); - var raw_b = args.get("b"); - if (raw_b == null) throw new IllegalArgumentException("Missing req param: b"); - var b = - raw_b instanceof Number ? ((Number) raw_b).intValue() : Integer.parseInt(raw_b.toString()); - var result = c.add(a, b); - return new io.jooby.mcp.McpResult(this.json).toCallToolResult(result, false); - } - - private io.modelcontextprotocol.spec.McpSchema.Prompt reviewCodePromptSpec() { - var args = new java.util.ArrayList(); - args.add(new io.modelcontextprotocol.spec.McpSchema.PromptArgument("language", null, false)); - args.add(new io.modelcontextprotocol.spec.McpSchema.PromptArgument("code", null, false)); - return new io.modelcontextprotocol.spec.McpSchema.Prompt("review_code", null, "", args); - } - - private io.modelcontextprotocol.spec.McpSchema.GetPromptResult reviewCode( - io.modelcontextprotocol.server.McpSyncServerExchange exchange, - io.modelcontextprotocol.common.McpTransportContext transportContext, - io.modelcontextprotocol.spec.McpSchema.GetPromptRequest req) { - var ctx = - exchange != null - ? (io.jooby.Context) exchange.transportContext().get("CTX") - : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); - var args = - req.arguments() != null - ? req.arguments() - : java.util.Collections.emptyMap(); - var c = this.factory.apply(ctx); - var raw_language = args.get("language"); - var language = raw_language != null ? raw_language.toString() : null; - var raw_code = args.get("code"); - var code = raw_code != null ? raw_code.toString() : null; - var result = c.reviewCode(language, code); - return new io.jooby.mcp.McpResult(this.json).toPromptResult(result); - } - - private io.modelcontextprotocol.spec.McpSchema.Resource getLogsResourceSpec() { - return new io.modelcontextprotocol.spec.McpSchema.Resource( - "file:///logs/app.log", "getLogs", null, "", null, null, null, null); - } - - private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getLogs( - io.modelcontextprotocol.server.McpSyncServerExchange exchange, - io.modelcontextprotocol.common.McpTransportContext transportContext, - io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest req) { - var ctx = - exchange != null - ? (io.jooby.Context) exchange.transportContext().get("CTX") - : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); - var args = java.util.Collections.emptyMap(); - var c = this.factory.apply(ctx); - var result = c.getLogs(); - return new io.jooby.mcp.McpResult(this.json).toResourceResult(req.uri(), result); - } - - private io.modelcontextprotocol.spec.McpSchema.ResourceTemplate - getUserProfileResourceTemplateSpec() { - return new io.modelcontextprotocol.spec.McpSchema.ResourceTemplate( - "file:///users/{id}/{name}/profile", "getUserProfile", null, "", null, null, null); - } - - private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getUserProfile( - io.modelcontextprotocol.server.McpSyncServerExchange exchange, - io.modelcontextprotocol.common.McpTransportContext transportContext, - io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest req) { - var ctx = - exchange != null - ? (io.jooby.Context) exchange.transportContext().get("CTX") - : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); - var uri = req.uri(); - var manager = - new io.modelcontextprotocol.util.DefaultMcpUriTemplateManager( - "file:///users/{id}/{name}/profile"); - var args = new java.util.HashMap(); - args.putAll(manager.extractVariableValues(uri)); - var c = this.factory.apply(ctx); - var raw_id = args.get("id"); - var id = raw_id != null ? raw_id.toString() : null; - var result = c.getUserProfile(id); - return new io.jooby.mcp.McpResult(this.json).toResourceResult(req.uri(), result); - } - - private io.modelcontextprotocol.spec.McpSchema.CompleteResult getUserProfileCompletionHandler( - io.modelcontextprotocol.server.McpSyncServerExchange exchange, - io.modelcontextprotocol.common.McpTransportContext transportContext, - io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) { - var ctx = - exchange != null - ? (io.jooby.Context) exchange.transportContext().get("CTX") - : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); - var c = this.factory.apply(ctx); - var targetArg = req.argument() != null ? req.argument().name() : ""; - var typedValue = req.argument() != null ? req.argument().value() : ""; - return switch (targetArg) { - case "id" -> { - var result = c.completeUserId(typedValue); - yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); - } - case "name" -> { - var result = c.completeUserName(typedValue); - yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); - } - default -> new io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of()); - }; - } - - private io.modelcontextprotocol.spec.McpSchema.CompleteResult reviewCodeCompletionHandler( - io.modelcontextprotocol.server.McpSyncServerExchange exchange, - io.modelcontextprotocol.common.McpTransportContext transportContext, - io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) { - var ctx = - exchange != null - ? (io.jooby.Context) exchange.transportContext().get("CTX") - : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); - var c = this.factory.apply(ctx); - var targetArg = req.argument() != null ? req.argument().name() : ""; - var typedValue = req.argument() != null ? req.argument().value() : ""; - return switch (targetArg) { - case "language" -> { - var result = c.reviewCodelanguage(typedValue); - yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); - } - default -> new io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of()); - }; - } -} diff --git a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java index a86736f288..6bab6ecef1 100644 --- a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java +++ b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java @@ -18,196 +18,201 @@ public void shouldGenerateMcpServer() throws Exception { .withMcpCode( source -> { assertThat(source) - .isEqualToNormalizingWhitespace( + .isEqualToNormalizingNewlines( """ package tests.i3830; @io.jooby.annotation.Generated(ExampleServer.class) public class ExampleServerMcp_ implements io.jooby.mcp.McpService { - protected java.util.function.Function factory; - - public ExampleServerMcp_() { - this(io.jooby.SneakyThrows.singleton(ExampleServer::new)); - } - - public ExampleServerMcp_(ExampleServer instance) { - setup(ctx -> instance); - } - - public ExampleServerMcp_(io.jooby.SneakyThrows.Supplier provider) { - setup(ctx -> (ExampleServer) provider.get()); - } - - public ExampleServerMcp_(io.jooby.SneakyThrows.Function, ExampleServer> provider) { - setup(ctx -> provider.apply(ExampleServer.class)); - } - - private void setup(java.util.function.Function factory) { - this.factory = factory; - } - - private io.modelcontextprotocol.json.McpJsonMapper json; - @Override - public void capabilities(io.modelcontextprotocol.spec.McpSchema.ServerCapabilities.Builder capabilities) { - capabilities.tools(true); - capabilities.prompts(true); - capabilities.resources(true, true); - capabilities.completions(); - } - - @Override - public String serverKey() { - return "example-server"; - } - - @Override - public java.util.List completions() { - var completions = new java.util.ArrayList(); - completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.ResourceReference("file:///users/{id}/{name}/profile"), (exchange, req) -> this.getUserProfileCompletionHandler(exchange, null, req))); - completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), (exchange, req) -> this.reviewCodeCompletionHandler(exchange, null, req))); - return completions; - } - - @Override - public java.util.List statelessCompletions() { - var completions = new java.util.ArrayList(); - completions.add(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.ResourceReference("file:///users/{id}/{name}/profile"), (ctx, req) -> this.getUserProfileCompletionHandler(null, ctx, req))); - completions.add(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), (ctx, req) -> this.reviewCodeCompletionHandler(null, ctx, req))); - return completions; - } - - @Override - public void install(io.jooby.Jooby app, io.modelcontextprotocol.server.McpSyncServer server) throws Exception { - this.json = app.getServices().require(io.modelcontextprotocol.json.McpJsonMapper.class); - var mapper = app.require(tools.jackson.databind.ObjectMapper.class); - var configBuilder = new com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder(com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12, com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON); - var schemaGenerator = new com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build()); - - server.addTool(new io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification(addToolSpec(mapper, schemaGenerator), (exchange, req) -> this.add(exchange, null, req))); - server.addPrompt(new io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification(reviewCodePromptSpec(), (exchange, req) -> this.reviewCode(exchange, null, req))); - server.addResource(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification(getLogsResourceSpec(), (exchange, req) -> this.getLogs(exchange, null, req))); - server.addResourceTemplate(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceTemplateSpecification(getUserProfileResourceTemplateSpec(), (exchange, req) -> this.getUserProfile(exchange, null, req))); - } - - @Override - public void install(io.jooby.Jooby app, io.modelcontextprotocol.server.McpStatelessSyncServer server) throws Exception { - this.json = app.getServices().require(io.modelcontextprotocol.json.McpJsonMapper.class); - var mapper = app.require(tools.jackson.databind.ObjectMapper.class); - var configBuilder = new com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder(com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12, com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON); - var schemaGenerator = new com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build()); - - server.addTool(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncToolSpecification(addToolSpec(mapper, schemaGenerator), (ctx, req) -> this.add(null, ctx, req))); - server.addPrompt(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncPromptSpecification(reviewCodePromptSpec(), (ctx, req) -> this.reviewCode(null, ctx, req))); - server.addResource(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceSpecification(getLogsResourceSpec(), (ctx, req) -> this.getLogs(null, ctx, req))); - server.addResourceTemplate(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceTemplateSpecification(getUserProfileResourceTemplateSpec(), (ctx, req) -> this.getUserProfile(null, ctx, req))); - } - - private io.modelcontextprotocol.spec.McpSchema.Tool addToolSpec(tools.jackson.databind.ObjectMapper mapper, com.github.victools.jsonschema.generator.SchemaGenerator schemaGenerator) { - var schema = mapper.createObjectNode(); - schema.put("type", "object"); - var props = schema.putObject("properties"); - var req = schema.putArray("required"); - props.set("a", schemaGenerator.generateSchema(int.class)); - req.add("a"); - props.set("b", schemaGenerator.generateSchema(int.class)); - req.add("b"); - return new io.modelcontextprotocol.spec.McpSchema.Tool("calculator", null, "A simple calculator", mapper.treeToValue(schema, io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), null, null, null); - } - - private io.modelcontextprotocol.spec.McpSchema.CallToolResult add(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.CallToolRequest req) { - var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); - var args = req.arguments() != null ? req.arguments() : java.util.Collections.emptyMap(); - var c = this.factory.apply(ctx); - var raw_a = args.get("a"); - if (raw_a == null) throw new IllegalArgumentException("Missing req param: a"); - var a = raw_a instanceof Number ? ((Number) raw_a).intValue() : Integer.parseInt(raw_a.toString()); - var raw_b = args.get("b"); - if (raw_b == null) throw new IllegalArgumentException("Missing req param: b"); - var b = raw_b instanceof Number ? ((Number) raw_b).intValue() : Integer.parseInt(raw_b.toString()); - var result = c.add(a, b); - return new io.jooby.mcp.McpResult(this.json).toCallToolResult(result, false); - } - - private io.modelcontextprotocol.spec.McpSchema.Prompt reviewCodePromptSpec() { - var args = new java.util.ArrayList(); - args.add(new io.modelcontextprotocol.spec.McpSchema.PromptArgument("language", null, false)); - args.add(new io.modelcontextprotocol.spec.McpSchema.PromptArgument("code", null, false)); - return new io.modelcontextprotocol.spec.McpSchema.Prompt("review_code", null, "", args); - } - - private io.modelcontextprotocol.spec.McpSchema.GetPromptResult reviewCode(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.GetPromptRequest req) { - var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); - var args = req.arguments() != null ? req.arguments() : java.util.Collections.emptyMap(); - var c = this.factory.apply(ctx); - var raw_language = args.get("language"); - var language = raw_language != null ? raw_language.toString() : null; - var raw_code = args.get("code"); - var code = raw_code != null ? raw_code.toString() : null; - var result = c.reviewCode(language, code); - return new io.jooby.mcp.McpResult(this.json).toPromptResult(result); - } - - private io.modelcontextprotocol.spec.McpSchema.Resource getLogsResourceSpec() { - return new io.modelcontextprotocol.spec.McpSchema.Resource("file:///logs/app.log", "getLogs", null, "", null, null, null, null); - } - - private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getLogs(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest req) { - var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); - var args = java.util.Collections.emptyMap(); - var c = this.factory.apply(ctx); - var result = c.getLogs(); - return new io.jooby.mcp.McpResult(this.json).toResourceResult(req.uri(), result); - } - - private io.modelcontextprotocol.spec.McpSchema.ResourceTemplate getUserProfileResourceTemplateSpec() { - return new io.modelcontextprotocol.spec.McpSchema.ResourceTemplate("file:///users/{id}/{name}/profile", "getUserProfile", null, "", null, null, null); - } - - private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getUserProfile(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest req) { - var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); - var uri = req.uri(); - var manager = new io.modelcontextprotocol.util.DefaultMcpUriTemplateManager("file:///users/{id}/{name}/profile"); - var args = new java.util.HashMap(); - args.putAll(manager.extractVariableValues(uri)); - var c = this.factory.apply(ctx); - var raw_id = args.get("id"); - var id = raw_id != null ? raw_id.toString() : null; - var result = c.getUserProfile(id); - return new io.jooby.mcp.McpResult(this.json).toResourceResult(req.uri(), result); - } - - private io.modelcontextprotocol.spec.McpSchema.CompleteResult getUserProfileCompletionHandler(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) { - var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); - var c = this.factory.apply(ctx); - var targetArg = req.argument() != null ? req.argument().name() : ""; - var typedValue = req.argument() != null ? req.argument().value() : ""; - return switch (targetArg) { - case "id" -> { - var result = c.completeUserId(typedValue); - yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); - } - case "name" -> { - var result = c.completeUserName(typedValue); - yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); - } - default -> new io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of()); - }; - } - - private io.modelcontextprotocol.spec.McpSchema.CompleteResult reviewCodeCompletionHandler(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) { - var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); - var c = this.factory.apply(ctx); - var targetArg = req.argument() != null ? req.argument().name() : ""; - var typedValue = req.argument() != null ? req.argument().value() : ""; - return switch (targetArg) { - case "language" -> { - var result = c.reviewCodelanguage(typedValue); - yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); - } - default -> new io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of()); - }; - } + protected java.util.function.Function factory; + + public ExampleServerMcp_() { + this(io.jooby.SneakyThrows.singleton(ExampleServer::new)); + } + + public ExampleServerMcp_(ExampleServer instance) { + setup(ctx -> instance); + } + + public ExampleServerMcp_(io.jooby.SneakyThrows.Supplier provider) { + setup(ctx -> (ExampleServer) provider.get()); + } + + public ExampleServerMcp_(io.jooby.SneakyThrows.Function, ExampleServer> provider) { + setup(ctx -> provider.apply(ExampleServer.class)); + } + + private void setup(java.util.function.Function factory) { + this.factory = factory; + } + + private io.modelcontextprotocol.json.McpJsonMapper json; + @Override + public void capabilities(io.modelcontextprotocol.spec.McpSchema.ServerCapabilities.Builder capabilities) { + capabilities.tools(true); + capabilities.prompts(true); + capabilities.resources(true, true); + capabilities.completions(); + } + + @Override + public String serverKey() { + return "example-server"; + } + + @Override + public java.util.List completions() { + var completions = new java.util.ArrayList(); + completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.ResourceReference("file:///users/{id}/{name}/profile"), (exchange, req) -> this.getUserProfileCompletionHandler(exchange, null, req))); + completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), (exchange, req) -> this.reviewCodeCompletionHandler(exchange, null, req))); + return completions; + } + + @Override + public java.util.List statelessCompletions() { + var completions = new java.util.ArrayList(); + completions.add(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.ResourceReference("file:///users/{id}/{name}/profile"), (ctx, req) -> this.getUserProfileCompletionHandler(null, ctx, req))); + completions.add(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), (ctx, req) -> this.reviewCodeCompletionHandler(null, ctx, req))); + return completions; + } + + @Override + public void install(io.jooby.Jooby app, io.modelcontextprotocol.server.McpSyncServer server) throws Exception { + this.json = app.getServices().require(io.modelcontextprotocol.json.McpJsonMapper.class); + var mapper = app.require(tools.jackson.databind.ObjectMapper.class); + var configBuilder = new com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder(com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12, com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON); + var schemaGenerator = new com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build()); + + server.addTool(new io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification(addToolSpec(mapper, schemaGenerator), (exchange, req) -> this.add(exchange, null, req))); + server.addPrompt(new io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification(reviewCodePromptSpec(), (exchange, req) -> this.reviewCode(exchange, null, req))); + server.addResource(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification(getLogsResourceSpec(), (exchange, req) -> this.getLogs(exchange, null, req))); + server.addResourceTemplate(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceTemplateSpecification(getUserProfileResourceTemplateSpec(), (exchange, req) -> this.getUserProfile(exchange, null, req))); + } + + @Override + public void install(io.jooby.Jooby app, io.modelcontextprotocol.server.McpStatelessSyncServer server) throws Exception { + this.json = app.getServices().require(io.modelcontextprotocol.json.McpJsonMapper.class); + var mapper = app.require(tools.jackson.databind.ObjectMapper.class); + var configBuilder = new com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder(com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12, com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON); + var schemaGenerator = new com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build()); + + server.addTool(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncToolSpecification(addToolSpec(mapper, schemaGenerator), (ctx, req) -> this.add(null, ctx, req))); + server.addPrompt(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncPromptSpecification(reviewCodePromptSpec(), (ctx, req) -> this.reviewCode(null, ctx, req))); + server.addResource(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceSpecification(getLogsResourceSpec(), (ctx, req) -> this.getLogs(null, ctx, req))); + server.addResourceTemplate(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceTemplateSpecification(getUserProfileResourceTemplateSpec(), (ctx, req) -> this.getUserProfile(null, ctx, req))); + } + + private io.modelcontextprotocol.spec.McpSchema.Tool addToolSpec(tools.jackson.databind.ObjectMapper mapper, com.github.victools.jsonschema.generator.SchemaGenerator schemaGenerator) { + var schema = mapper.createObjectNode(); + schema.put("type", "object"); + var props = schema.putObject("properties"); + var req = schema.putArray("required"); + var schema_a = schemaGenerator.generateSchema(int.class); + schema_a.put("description", "1st number"); + props.set("a", schema_a); + req.add("a"); + var schema_b = schemaGenerator.generateSchema(int.class); + schema_b.put("description", "2nd number"); + props.set("b", schema_b); + req.add("b"); + return new io.modelcontextprotocol.spec.McpSchema.Tool("calculator", null, "Add two numbers.A simple calculator.", mapper.treeToValue(schema, io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), null, null, null); + } + + private io.modelcontextprotocol.spec.McpSchema.CallToolResult add(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.CallToolRequest req) { + var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var args = req.arguments() != null ? req.arguments() : java.util.Collections.emptyMap(); + var c = this.factory.apply(ctx); + var raw_a = args.get("a"); + if (raw_a == null) throw new IllegalArgumentException("Missing req param: a"); + var a = raw_a instanceof Number ? ((Number) raw_a).intValue() : Integer.parseInt(raw_a.toString()); + var raw_b = args.get("b"); + if (raw_b == null) throw new IllegalArgumentException("Missing req param: b"); + var b = raw_b instanceof Number ? ((Number) raw_b).intValue() : Integer.parseInt(raw_b.toString()); + var result = c.add(a, b); + return new io.jooby.mcp.McpResult(this.json).toCallToolResult(result, false); + } + + private io.modelcontextprotocol.spec.McpSchema.Prompt reviewCodePromptSpec() { + var args = new java.util.ArrayList(); + args.add(new io.modelcontextprotocol.spec.McpSchema.PromptArgument("language", null, false)); + args.add(new io.modelcontextprotocol.spec.McpSchema.PromptArgument("code", null, false)); + return new io.modelcontextprotocol.spec.McpSchema.Prompt("review_code", null, "Reviews the given code snippet in the context of the specified programming language.", args); + } + + private io.modelcontextprotocol.spec.McpSchema.GetPromptResult reviewCode(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.GetPromptRequest req) { + var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var args = req.arguments() != null ? req.arguments() : java.util.Collections.emptyMap(); + var c = this.factory.apply(ctx); + var raw_language = args.get("language"); + var language = raw_language != null ? raw_language.toString() : null; + var raw_code = args.get("code"); + var code = raw_code != null ? raw_code.toString() : null; + var result = c.reviewCode(language, code); + return new io.jooby.mcp.McpResult(this.json).toPromptResult(result); + } + + private io.modelcontextprotocol.spec.McpSchema.Resource getLogsResourceSpec() { + return new io.modelcontextprotocol.spec.McpSchema.Resource("file:///logs/app.log", "getLogs", null, "", null, null, null, null); + } + + private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getLogs(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest req) { + var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var args = java.util.Collections.emptyMap(); + var c = this.factory.apply(ctx); + var result = c.getLogs(); + return new io.jooby.mcp.McpResult(this.json).toResourceResult(req.uri(), result); + } + + private io.modelcontextprotocol.spec.McpSchema.ResourceTemplate getUserProfileResourceTemplateSpec() { + return new io.modelcontextprotocol.spec.McpSchema.ResourceTemplate("file:///users/{id}/{name}/profile", "getUserProfile", null, "", null, null, null); + } + + private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getUserProfile(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest req) { + var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var uri = req.uri(); + var manager = new io.modelcontextprotocol.util.DefaultMcpUriTemplateManager("file:///users/{id}/{name}/profile"); + var args = new java.util.HashMap(); + args.putAll(manager.extractVariableValues(uri)); + var c = this.factory.apply(ctx); + var raw_id = args.get("id"); + var id = raw_id != null ? raw_id.toString() : null; + var result = c.getUserProfile(id); + return new io.jooby.mcp.McpResult(this.json).toResourceResult(req.uri(), result); + } + + private io.modelcontextprotocol.spec.McpSchema.CompleteResult getUserProfileCompletionHandler(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) { + var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var c = this.factory.apply(ctx); + var targetArg = req.argument() != null ? req.argument().name() : ""; + var typedValue = req.argument() != null ? req.argument().value() : ""; + return switch (targetArg) { + case "id" -> { + var result = c.completeUserId(typedValue); + yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); + } + case "name" -> { + var result = c.completeUserName(typedValue); + yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); + } + default -> new io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of()); + }; + } + + private io.modelcontextprotocol.spec.McpSchema.CompleteResult reviewCodeCompletionHandler(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) { + var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var c = this.factory.apply(ctx); + var targetArg = req.argument() != null ? req.argument().name() : ""; + var typedValue = req.argument() != null ? req.argument().value() : ""; + return switch (targetArg) { + case "language" -> { + var result = c.reviewCodelanguage(typedValue); + yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); + } + default -> new io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of()); + }; + } } + """); }); } diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocNode.java b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocNode.java index 063c85fcc0..54469c2063 100644 --- a/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocNode.java +++ b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocNode.java @@ -10,7 +10,10 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; import com.puppycrawl.tools.checkstyle.DetailNodeTreeStringPrinter; import com.puppycrawl.tools.checkstyle.JavadocDetailNodeParser; @@ -68,6 +71,12 @@ public String getDescription() { return description.isEmpty() ? null : description; } + public String getFullDescription() { + return Stream.of(getSummary(), getDescription()) + .filter(Objects::nonNull) + .collect(Collectors.joining()); + } + public String getText() { return getText(JavaDocStream.forward(javadoc, JAVADOC_TAG).toList(), false); } diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocParser.java b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocParser.java index 6dbbf7e844..0d07b2422f 100644 --- a/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocParser.java +++ b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocParser.java @@ -13,6 +13,7 @@ import static java.util.Optional.ofNullable; import java.io.File; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -330,9 +331,30 @@ private DetailAST resolveType(String typeName) { } private Optional lookup(Path path) { + var direct = + baseDir.stream() + .map(parentDir -> parentDir.resolve(path)) + .filter(Files::exists) + .findFirst(); + if (direct.isPresent()) { + return direct; + } return baseDir.stream() - .map(parentDir -> parentDir.resolve(path)) - .filter(Files::exists) + .map( + parentDir -> { + try (var stream = Files.walk(parentDir)) { + return stream + .filter(Files::isRegularFile) + // Check if the file ends with the path provided + .filter(p -> p.endsWith(path.toString())) + .findFirst() + .orElse(null); + } catch (IOException e) { + // Log error or handle permission issues + return null; + } + }) + .filter(Objects::nonNull) .findFirst(); } diff --git a/tests/src/test/java/io/jooby/i3830/CalculatorTools.java b/tests/src/test/java/io/jooby/i3830/CalculatorTools.java index 120b031b0c..efad579ae9 100644 --- a/tests/src/test/java/io/jooby/i3830/CalculatorTools.java +++ b/tests/src/test/java/io/jooby/i3830/CalculatorTools.java @@ -18,7 +18,15 @@ public class CalculatorTools { // --- TOOLS --- - @McpTool(name = "add_numbers", description = "Adds two integers together and returns the result.") + + /** + * Adds two integers together and returns the result. + * + * @param a The first number to add. + * @param b The second number to add. + * @return The sum of the two numbers. + */ + @McpTool(name = "add_numbers") public int add(int a, int b) { return a + b; } @@ -33,7 +41,7 @@ public String mathTutor(String topic) { // --- RESOURCES --- @McpResource( - value = "calculator://manual/usage", + uri = "calculator://manual/usage", name = "Calculator Manual", description = "Instructions on how to use the calculator") public String manual() { @@ -41,7 +49,7 @@ public String manual() { } @McpResource( - value = "calculator://history/{user}", + uri = "calculator://history/{user}", name = "User History", description = "Retrieves the calculation history for a specific user") public String history(String user) { From 96d3f3498d21d7046b21b714c0cfd27fe5bb5fcd Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 28 Mar 2026 12:41:45 -0300 Subject: [PATCH 21/37] - move mcp annotation to his own module --- .../java/io/jooby/apt/JoobyProcessor.java | 9 +++-- .../java/io/jooby/internal/apt/McpRoute.java | 38 ++++++++++--------- .../java/io/jooby/internal/apt/McpRouter.java | 9 +++-- .../io/jooby/internal/apt/MvcParameter.java | 2 +- .../test/java/tests/i3830/ExampleServer.java | 2 +- .../src/test/java/tests/i3830/Issue3830.java | 10 +++-- .../jooby/annotation/mcp}/McpCompletion.java | 2 +- .../io/jooby/annotation/mcp}/McpParam.java | 2 +- .../io/jooby/annotation/mcp}/McpPrompt.java | 2 +- .../io/jooby/annotation/mcp}/McpResource.java | 2 +- .../io/jooby/annotation/mcp}/McpServer.java | 2 +- .../io/jooby/annotation/mcp}/McpTool.java | 2 +- .../java/io/jooby/i3830/CalculatorTools.java | 8 ++-- .../test/java/io/jooby/i3830/UserTools.java | 2 +- 14 files changed, 51 insertions(+), 41 deletions(-) rename {jooby/src/main/java/io/jooby/annotation => modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp}/McpCompletion.java (94%) rename {jooby/src/main/java/io/jooby/annotation => modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp}/McpParam.java (96%) rename {jooby/src/main/java/io/jooby/annotation => modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp}/McpPrompt.java (95%) rename {jooby/src/main/java/io/jooby/annotation => modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp}/McpResource.java (98%) rename {jooby/src/main/java/io/jooby/annotation => modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp}/McpServer.java (95%) rename {jooby/src/main/java/io/jooby/annotation => modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp}/McpTool.java (95%) diff --git a/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java b/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java index 6a8779db60..348e95b840 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java +++ b/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java @@ -271,10 +271,11 @@ public Set getSupportedAnnotationTypes() { supportedTypes.add("io.jooby.annotation.Trpc.Query"); supportedTypes.add("io.jooby.annotation.JsonRpc"); // Add MCP Annotations - supportedTypes.add("io.jooby.annotation.McpTool"); - supportedTypes.add("io.jooby.annotation.McpPrompt"); - supportedTypes.add("io.jooby.annotation.McpResource"); - supportedTypes.add("io.jooby.annotation.McpServer"); + supportedTypes.add("io.jooby.annotation.mcp.McpCompletion"); + supportedTypes.add("io.jooby.annotation.mcp.McpTool"); + supportedTypes.add("io.jooby.annotation.mcp.McpPrompt"); + supportedTypes.add("io.jooby.annotation.mcp.McpResource"); + supportedTypes.add("io.jooby.annotation.mcp.McpServer"); return supportedTypes; } diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java index 75f201363d..5a4ec9377d 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java @@ -28,21 +28,21 @@ public McpRoute(McpRouter router, ExecutableElement method) { } private void checkMcpAnnotations() { - if (AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.McpTool") + if (AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.mcp.McpTool") != null) { this.isMcpTool = true; } - if (AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.McpPrompt") + if (AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.mcp.McpPrompt") != null) { this.isMcpPrompt = true; } - if (AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.McpCompletion") + if (AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.mcp.McpCompletion") != null) { this.isMcpCompletion = true; } var resourceAnno = - AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.McpResource"); + AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.mcp.McpResource"); if (resourceAnno != null) { String uri = AnnotationSupport.findAnnotationValue(resourceAnno, "uri"::equals).stream() @@ -91,11 +91,11 @@ public List generateMcpDefinitionMethod(boolean kt) { var methodDescription = method.map(JavaDocNode::getDescription).orElse(""); var methodSummaryAndDescription = method.map(JavaDocNode::getFullDescription).orElse(""); if (isMcpTool()) { - String toolName = extractAnnotationValue("io.jooby.annotation.McpTool", "name"); + String toolName = extractAnnotationValue("io.jooby.annotation.mcp.McpTool", "name"); if (toolName.isEmpty()) { toolName = getMethodName(); } - String description = extractAnnotationValue("io.jooby.annotation.McpTool", "description"); + String description = extractAnnotationValue("io.jooby.annotation.mcp.McpTool", "description"); if (description.isEmpty()) { description = methodSummaryAndDescription; } @@ -165,7 +165,7 @@ public List generateMcpDefinitionMethod(boolean kt) { if (varEl != null) { var paramAnno = - AnnotationSupport.findAnnotationByName(varEl, "io.jooby.annotation.McpParam"); + AnnotationSupport.findAnnotationByName(varEl, "io.jooby.annotation.mcp.McpParam"); if (paramAnno != null) { paramDescription = AnnotationSupport.findAnnotationValue(paramAnno, "description"::equals).stream() @@ -329,11 +329,12 @@ public List generateMcpDefinitionMethod(boolean kt) { buffer.add(statement(indent(4), "}\n")); } else if (isMcpPrompt()) { - String promptName = extractAnnotationValue("io.jooby.annotation.McpPrompt", "name"); + String promptName = extractAnnotationValue("io.jooby.annotation.mcp.McpPrompt", "name"); if (promptName.isEmpty()) { promptName = getMethodName(); } - String description = extractAnnotationValue("io.jooby.annotation.McpPrompt", "description"); + String description = + extractAnnotationValue("io.jooby.annotation.mcp.McpPrompt", "description"); if (description.isEmpty()) { description = methodSummaryAndDescription; } @@ -419,16 +420,17 @@ public List generateMcpDefinitionMethod(boolean kt) { buffer.add(statement(indent(4), "}\n")); } else if (isMcpResource() || isMcpResourceTemplate()) { - var uri = extractAnnotationValue("io.jooby.annotation.McpResource", "uri"); - var name = extractAnnotationValue("io.jooby.annotation.McpResource", "name"); + var uri = extractAnnotationValue("io.jooby.annotation.mcp.McpResource", "uri"); + var name = extractAnnotationValue("io.jooby.annotation.mcp.McpResource", "name"); if (name.isEmpty()) { name = getMethodName(); } - var title = extractAnnotationValue("io.jooby.annotation.McpResource", "title"); - var description = extractAnnotationValue("io.jooby.annotation.McpResource", "description"); - var mimeType = extractAnnotationValue("io.jooby.annotation.McpResource", "mimeType"); - var sizeStr = extractAnnotationValue("io.jooby.annotation.McpResource", "size"); + var title = extractAnnotationValue("io.jooby.annotation.mcp.McpResource", "title"); + var description = + extractAnnotationValue("io.jooby.annotation.mcp.McpResource", "description"); + var mimeType = extractAnnotationValue("io.jooby.annotation.mcp.McpResource", "mimeType"); + var sizeStr = extractAnnotationValue("io.jooby.annotation.mcp.McpResource", "size"); // Prepare standard arguments safely var titleArg = @@ -456,7 +458,7 @@ public List generateMcpDefinitionMethod(boolean kt) { // It looks like: @...McpAnnotations(audience={"USER"}, priority=1.0, lastModified="2024") String annotationsArg = "null"; String rawAnnotations = - extractAnnotationValue("io.jooby.annotation.McpResource", "annotations"); + extractAnnotationValue("io.jooby.annotation.mcp.McpResource", "annotations"); boolean hasAnnotations = rawAnnotations.contains("priority="); @@ -708,7 +710,7 @@ public List generateMcpHandlerMethod(boolean kt) { semicolon(kt))); } } else if (isMcpResource() || isMcpResourceTemplate()) { - String uriTemplate = extractAnnotationValue("io.jooby.annotation.McpResource", "uri"); + String uriTemplate = extractAnnotationValue("io.jooby.annotation.mcp.McpResource", "uri"); boolean isTemplate = isMcpResourceTemplate(); if (isTemplate) { @@ -955,4 +957,6 @@ private boolean hasOutputSchema() { && !isLangClass && !isMcpClass; } + + private record McpAnnotation(String name, String value) {} } diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java index 44e40ff372..e8ff7b0db8 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java @@ -59,7 +59,8 @@ public String getGeneratedType() { } private String getMcpServerKey() { - var annotation = AnnotationSupport.findAnnotationByName(clazz, "io.jooby.annotation.McpServer"); + var annotation = + AnnotationSupport.findAnnotationByName(clazz, "io.jooby.annotation.mcp.McpServer"); if (annotation != null) { return AnnotationSupport.findAnnotationValue(annotation, VALUE).stream() .findFirst() @@ -79,7 +80,7 @@ private String findTargetMethodName(String ref) { if (route.isMcpPrompt()) { var annotation = AnnotationSupport.findAnnotationByName( - route.getMethod(), "io.jooby.annotation.McpPrompt"); + route.getMethod(), "io.jooby.annotation.mcp.McpPrompt"); var name = annotation != null ? AnnotationSupport.findAnnotationValue(annotation, "name"::equals).stream() @@ -95,7 +96,7 @@ private String findTargetMethodName(String ref) { } else if (route.isMcpResource() || route.isMcpResourceTemplate()) { var annotation = AnnotationSupport.findAnnotationByName( - route.getMethod(), "io.jooby.annotation.McpResource"); + route.getMethod(), "io.jooby.annotation.mcp.McpResource"); var uri = annotation != null ? AnnotationSupport.findAnnotationValue(annotation, "uri"::equals).stream() @@ -138,7 +139,7 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { for (var route : completionRoutes) { var annotation = AnnotationSupport.findAnnotationByName( - route.getMethod(), "io.jooby.annotation.McpCompletion"); + route.getMethod(), "io.jooby.annotation.mcp.McpCompletion"); String ref = AnnotationSupport.findAnnotationValue(annotation, "value"::equals).stream() .findFirst() diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java index 684b4fcb73..2be87bde07 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java @@ -42,7 +42,7 @@ public String getName() { } public String getMcpName() { - var annotation = annotations.get("io.jooby.annotation.McpParam"); + var annotation = annotations.get("io.jooby.annotation.mcp.McpParam"); if (annotation != null) { var customName = io.jooby.internal.apt.AnnotationSupport.findAnnotationValue(annotation, "name"::equals) diff --git a/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java index a452e8a625..4b909bdf69 100644 --- a/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java +++ b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java @@ -8,7 +8,7 @@ import java.util.List; import java.util.Map; -import io.jooby.annotation.*; +import io.jooby.annotation.mcp.*; @McpServer("example-server") public class ExampleServer { diff --git a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java index 6bab6ecef1..f525796758 100644 --- a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java +++ b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java @@ -152,7 +152,9 @@ private io.modelcontextprotocol.spec.McpSchema.GetPromptResult reviewCode(io.mod } private io.modelcontextprotocol.spec.McpSchema.Resource getLogsResourceSpec() { - return new io.modelcontextprotocol.spec.McpSchema.Resource("file:///logs/app.log", "getLogs", null, "", null, null, null, null); + var audience = java.util.List.of(io.modelcontextprotocol.spec.McpSchema.Role.USER); + var annotations = new io.modelcontextprotocol.spec.McpSchema.Annotations(audience, 1.5D, "1"); + return new io.modelcontextprotocol.spec.McpSchema.Resource("file:///logs/app.log", "Application Logs", "Logs Title.", "Log description Suspendisse potenti.", io.jooby.MediaType.byFileExtension("file:///logs/app.log", "text/plain").getValue(), 1024L, annotations, null); } private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getLogs(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest req) { @@ -164,7 +166,7 @@ private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getLogs(io.mod } private io.modelcontextprotocol.spec.McpSchema.ResourceTemplate getUserProfileResourceTemplateSpec() { - return new io.modelcontextprotocol.spec.McpSchema.ResourceTemplate("file:///users/{id}/{name}/profile", "getUserProfile", null, "", null, null, null); + return new io.modelcontextprotocol.spec.McpSchema.ResourceTemplate("file:///users/{id}/{name}/profile", "getUserProfile", "Resource Template.", null, "application/json", null, null); } private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getUserProfile(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest req) { @@ -176,7 +178,9 @@ private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getUserProfile var c = this.factory.apply(ctx); var raw_id = args.get("id"); var id = raw_id != null ? raw_id.toString() : null; - var result = c.getUserProfile(id); + var raw_name = args.get("name"); + var name = raw_name != null ? raw_name.toString() : null; + var result = c.getUserProfile(id, name); return new io.jooby.mcp.McpResult(this.json).toResourceResult(req.uri(), result); } diff --git a/jooby/src/main/java/io/jooby/annotation/McpCompletion.java b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpCompletion.java similarity index 94% rename from jooby/src/main/java/io/jooby/annotation/McpCompletion.java rename to modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpCompletion.java index 54fb7f6f29..d557eb4c57 100644 --- a/jooby/src/main/java/io/jooby/annotation/McpCompletion.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpCompletion.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.annotation; +package io.jooby.annotation.mcp; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/jooby/src/main/java/io/jooby/annotation/McpParam.java b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpParam.java similarity index 96% rename from jooby/src/main/java/io/jooby/annotation/McpParam.java rename to modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpParam.java index 873b4c1c51..48aba66436 100644 --- a/jooby/src/main/java/io/jooby/annotation/McpParam.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpParam.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.annotation; +package io.jooby.annotation.mcp; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/jooby/src/main/java/io/jooby/annotation/McpPrompt.java b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpPrompt.java similarity index 95% rename from jooby/src/main/java/io/jooby/annotation/McpPrompt.java rename to modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpPrompt.java index 7d653c3c91..d852aadfac 100644 --- a/jooby/src/main/java/io/jooby/annotation/McpPrompt.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpPrompt.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.annotation; +package io.jooby.annotation.mcp; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/jooby/src/main/java/io/jooby/annotation/McpResource.java b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpResource.java similarity index 98% rename from jooby/src/main/java/io/jooby/annotation/McpResource.java rename to modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpResource.java index 3338431205..04d0393733 100644 --- a/jooby/src/main/java/io/jooby/annotation/McpResource.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpResource.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.annotation; +package io.jooby.annotation.mcp; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/jooby/src/main/java/io/jooby/annotation/McpServer.java b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpServer.java similarity index 95% rename from jooby/src/main/java/io/jooby/annotation/McpServer.java rename to modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpServer.java index 080bb736bb..97c604b39f 100644 --- a/jooby/src/main/java/io/jooby/annotation/McpServer.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpServer.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.annotation; +package io.jooby.annotation.mcp; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/jooby/src/main/java/io/jooby/annotation/McpTool.java b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpTool.java similarity index 95% rename from jooby/src/main/java/io/jooby/annotation/McpTool.java rename to modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpTool.java index c5f12c92de..a16a276449 100644 --- a/jooby/src/main/java/io/jooby/annotation/McpTool.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpTool.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.annotation; +package io.jooby.annotation.mcp; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/tests/src/test/java/io/jooby/i3830/CalculatorTools.java b/tests/src/test/java/io/jooby/i3830/CalculatorTools.java index efad579ae9..84a6148cbe 100644 --- a/tests/src/test/java/io/jooby/i3830/CalculatorTools.java +++ b/tests/src/test/java/io/jooby/i3830/CalculatorTools.java @@ -8,10 +8,10 @@ import java.util.List; import java.util.Optional; -import io.jooby.annotation.McpCompletion; -import io.jooby.annotation.McpPrompt; -import io.jooby.annotation.McpResource; -import io.jooby.annotation.McpTool; +import io.jooby.annotation.mcp.McpCompletion; +import io.jooby.annotation.mcp.McpPrompt; +import io.jooby.annotation.mcp.McpResource; +import io.jooby.annotation.mcp.McpTool; import io.modelcontextprotocol.server.McpSyncServerExchange; /** A collection of tools, prompts, and resources exposed to the LLM via MCP. */ diff --git a/tests/src/test/java/io/jooby/i3830/UserTools.java b/tests/src/test/java/io/jooby/i3830/UserTools.java index 181cc5bb61..018cfbb379 100644 --- a/tests/src/test/java/io/jooby/i3830/UserTools.java +++ b/tests/src/test/java/io/jooby/i3830/UserTools.java @@ -5,7 +5,7 @@ */ package io.jooby.i3830; -import io.jooby.annotation.McpTool; +import io.jooby.annotation.mcp.McpTool; public class UserTools { From 352923a0f02731f027b25e82ecc94b53736874d1 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 28 Mar 2026 12:51:31 -0300 Subject: [PATCH 22/37] - use real McpSchema.Role enum on McpResource annotation - simplify mcp description lookup --- .../java/io/jooby/internal/apt/McpRoute.java | 22 ++----------------- .../io/jooby/internal/apt/MvcParameter.java | 12 ++++++++++ .../test/java/tests/i3830/ExampleServer.java | 3 ++- .../io/jooby/annotation/mcp/McpResource.java | 11 +++------- 4 files changed, 19 insertions(+), 29 deletions(-) diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java index 5a4ec9377d..f74c24c211 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java @@ -153,28 +153,10 @@ public List generateMcpDefinitionMethod(boolean kt) { || type.equals("io.jooby.Context")) continue; var mcpName = param.getMcpName(); - var javaName = param.getName(); // 1. Extract the description from the @McpParam annotation - var paramDescription = ""; - var varEl = - this.method.getParameters().stream() - .filter(p -> p.getSimpleName().toString().equals(javaName)) - .findFirst() - .orElse(null); - - if (varEl != null) { - var paramAnno = - AnnotationSupport.findAnnotationByName(varEl, "io.jooby.annotation.mcp.McpParam"); - if (paramAnno != null) { - paramDescription = - AnnotationSupport.findAnnotationValue(paramAnno, "description"::equals).stream() - .findFirst() - .map(v -> v.replace("\"", "")) - .orElse(""); - } - } - if (paramDescription.isEmpty()) { + var paramDescription = param.getMcpDescription(); + if (paramDescription == null) { paramDescription = method.map(it -> it.getParameterDoc(param.getName())).orElse(""); } diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java index 2be87bde07..6a691cf2bc 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java @@ -58,6 +58,18 @@ public String getMcpName() { return getName(); } + public String getMcpDescription() { + var annotation = annotations.get("io.jooby.annotation.mcp.McpParam"); + if (annotation != null) { + return io.jooby.internal.apt.AnnotationSupport.findAnnotationValue( + annotation, "description"::equals) + .stream() + .findFirst() + .orElse(null); + } + return null; + } + public String generateMapping(boolean kt) { var strategy = annotations.entrySet().stream() diff --git a/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java index 4b909bdf69..0961b1be96 100644 --- a/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java +++ b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java @@ -9,6 +9,7 @@ import java.util.Map; import io.jooby.annotation.mcp.*; +import io.modelcontextprotocol.spec.McpSchema; @McpServer("example-server") public class ExampleServer { @@ -46,7 +47,7 @@ public String reviewCode( size = 1024, annotations = { @McpResource.McpAnnotations( - audience = McpResource.Role.USER, + audience = McpSchema.Role.USER, lastModified = "1", priority = 1.5) }) diff --git a/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpResource.java b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpResource.java index 04d0393733..73fdb7a4b7 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpResource.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpResource.java @@ -10,6 +10,8 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import io.modelcontextprotocol.spec.McpSchema; + /** * Exposes a method as an MCP Resource or Resource Template. * @@ -57,13 +59,6 @@ McpAnnotations[] annotations() default {}; // Using an array is the safest way to provide an "empty" default in - // Java annotations - - enum Role { - USER, - ASSISTANT - } - @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.ANNOTATION_TYPE) @interface McpAnnotations { @@ -72,7 +67,7 @@ enum Role { * Describes who the intended customer of this object or data is. It can include multiple * entries to indicate content useful for multiple audiences (e.g., [“user”, “assistant”]). */ - Role[] audience(); + McpSchema.Role[] audience(); /** The date and time (in ISO 8601 format) when the resource was last modified. */ String lastModified() default ""; From 6ec7b653439b3eb73f4e18d049e6bf9e92726aaf Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 28 Mar 2026 13:05:17 -0300 Subject: [PATCH 23/37] - remove duplicated code while parsing McpResourceAnnotation --- .../java/io/jooby/internal/apt/McpRoute.java | 102 +++++++++--------- .../io/jooby/annotation/mcp/McpResource.java | 3 +- 2 files changed, 54 insertions(+), 51 deletions(-) diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java index f74c24c211..d09d3bb1b3 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java @@ -10,6 +10,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; import javax.lang.model.element.ExecutableElement; @@ -435,14 +436,8 @@ public List generateMcpDefinitionMethod(boolean kt) { String sizeArg = (sizeStr.isEmpty() || sizeStr.equals("-1")) ? "null" : sizeStr + "L"; // --- NESTED ANNOTATION EXTRACTION --- - // We parse the string representation of the annotation to avoid massive APT ElementVisitor - // boilerplate. - // It looks like: @...McpAnnotations(audience={"USER"}, priority=1.0, lastModified="2024") String annotationsArg = "null"; - String rawAnnotations = - extractAnnotationValue("io.jooby.annotation.mcp.McpResource", "annotations"); - - boolean hasAnnotations = rawAnnotations.contains("priority="); + var annotation = parseResourceAnnotation(); var isTemplate = isMcpResourceTemplate(); var specType = isTemplate ? "ResourceTemplate" : "Resource"; @@ -459,32 +454,15 @@ public List generateMcpDefinitionMethod(boolean kt) { " {")); // Build the Kotlin ResourceAnnotations object if present - if (hasAnnotations) { - annotationsArg = "annotations"; - String audienceList = - rawAnnotations.contains("USER") && rawAnnotations.contains("ASSISTANT") - ? "listOf(io.modelcontextprotocol.spec.McpSchema.Role.USER," - + " io.modelcontextprotocol.spec.McpSchema.Role.ASSISTANT)" - : (rawAnnotations.contains("USER") - ? "listOf(io.modelcontextprotocol.spec.McpSchema.Role.USER)" - : (rawAnnotations.contains("ASSISTANT") - ? "listOf(io.modelcontextprotocol.spec.McpSchema.Role.ASSISTANT)" - : "emptyList()")); - - String priority = rawAnnotations.replaceAll(".*priority=([0-9.]+).*", "$1"); - var lastMod = - rawAnnotations.contains("lastModified=") - ? string(rawAnnotations.replaceAll(".*lastModified=\"([^\"]+)\".*", "$1")) - : "null"; - - buffer.add(statement(indent(6), "val audience = ", audienceList)); + if (annotation != null) { + buffer.add(statement(indent(6), "val audience = ", "listOf(", annotation.audience, "")); buffer.add( statement( indent(6), "val annotations = io.modelcontextprotocol.spec.McpSchema.Annotations(audience, ", - priority, + annotation.priority, ", ", - lastMod, + annotation.lastModified, ")")); } @@ -539,33 +517,25 @@ public List generateMcpDefinitionMethod(boolean kt) { "Spec() {")); // Build the Java ResourceAnnotations object if present - if (hasAnnotations) { + if (annotation != null) { annotationsArg = "annotations"; - String audienceList = - rawAnnotations.contains("USER") && rawAnnotations.contains("ASSISTANT") - ? "java.util.List.of(io.modelcontextprotocol.spec.McpSchema.Role.USER," - + " io.modelcontextprotocol.spec.McpSchema.Role.ASSISTANT)" - : (rawAnnotations.contains("USER") - ? "java.util.List.of(io.modelcontextprotocol.spec.McpSchema.Role.USER)" - : (rawAnnotations.contains("ASSISTANT") - ? "java.util.List.of(io.modelcontextprotocol.spec.McpSchema.Role.ASSISTANT)" - : "java.util.Collections.emptyList()")); - - String priority = rawAnnotations.replaceAll(".*priority=([0-9.]+).*", "$1"); - var lastMod = - rawAnnotations.contains("lastModified=") - ? string(rawAnnotations.replaceAll(".*lastModified=\"([^\"]+)\".*", "$1")) - : "null"; - - buffer.add(statement(indent(6), "var audience = ", audienceList, semicolon(kt))); + + buffer.add( + statement( + indent(6), + "var audience = ", + "java.util.List.of(", + annotation.audience, + ")", + semicolon(kt))); buffer.add( statement( indent(6), "var annotations = new" + " io.modelcontextprotocol.spec.McpSchema.Annotations(audience, ", - priority, + annotation.priority, "D, ", - lastMod, + annotation.lastModified, ")", semicolon(kt))); } @@ -940,5 +910,39 @@ private boolean hasOutputSchema() { && !isMcpClass; } - private record McpAnnotation(String name, String value) {} + private McpAnnotation parseResourceAnnotation() { + String rawAnnotations = + extractAnnotationValue("io.jooby.annotation.mcp.McpResource", "annotations"); + + boolean hasAnnotations = rawAnnotations.contains("priority="); + + if (!hasAnnotations) { + return null; + } + + var audienceList = new ArrayList(); + var annotationMap = + Map.of( + "USER", + "io.modelcontextprotocol.spec.McpSchema.Role.USER", + "ASSISTANT", + "io.modelcontextprotocol.spec.McpSchema.Role.ASSISTANT"); + for (var entry : annotationMap.entrySet()) { + if (rawAnnotations.contains(entry.getKey())) { + audienceList.add(entry.getValue()); + } + } + if (audienceList.isEmpty()) { + audienceList.add(annotationMap.get("USER")); + } + var priority = rawAnnotations.replaceAll(".*priority=([0-9.]+).*", "$1"); + var lastMod = + rawAnnotations.contains("lastModified=") + ? string(rawAnnotations.replaceAll(".*lastModified=\"([^\"]+)\".*", "$1")) + : "null"; + + return new McpAnnotation(String.join(", ", audienceList), lastMod.toString(), priority); + } + + private record McpAnnotation(String audience, String lastModified, String priority) {} } diff --git a/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpResource.java b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpResource.java index 73fdb7a4b7..70d13a7bd5 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpResource.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpResource.java @@ -56,8 +56,7 @@ int size() default -1; /** Optional MCP metadata annotations for this resource. */ - McpAnnotations[] - annotations() default {}; // Using an array is the safest way to provide an "empty" default in + McpAnnotations[] annotations() default {}; @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.ANNOTATION_TYPE) From 5d968b84b155a8364e6e2b5f6801c2ae8b20e5a3 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 28 Mar 2026 13:26:15 -0300 Subject: [PATCH 24/37] feat(mcp): enhance @McpTool with title and advanced annotations This commit expands the `@McpTool` annotation to fully support the latest Model Context Protocol (MCP) tool specification, allowing developers to provide richer metadata to the LLM. Details: * Added support for the `title` attribute in `@McpTool` for human-readable display names. * Introduced nested `@McpAnnotations` for tools to support execution hints: `readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint`. * Updated the `McpRoute` APT generator to parse the nested tool annotations from their string representation, falling back to spec defaults when omitted. * Fixed compilation errors in the code generator by correctly aligning the constructor arguments for `McpSchema.ToolAnnotations`. --- .../java/io/jooby/internal/apt/McpRoute.java | 110 ++++++++++++++++-- .../test/java/tests/i3830/ExampleServer.java | 13 +-- .../src/test/java/tests/i3830/Issue3830.java | 3 +- .../io/jooby/annotation/mcp/McpResource.java | 6 +- .../java/io/jooby/annotation/mcp/McpTool.java | 50 ++++++++ 5 files changed, 165 insertions(+), 17 deletions(-) diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java index d09d3bb1b3..ec0e3f986f 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java @@ -91,14 +91,23 @@ public List generateMcpDefinitionMethod(boolean kt) { var methodSummary = method.map(JavaDocNode::getSummary).orElse(""); var methodDescription = method.map(JavaDocNode::getDescription).orElse(""); var methodSummaryAndDescription = method.map(JavaDocNode::getFullDescription).orElse(""); + if (isMcpTool()) { String toolName = extractAnnotationValue("io.jooby.annotation.mcp.McpTool", "name"); if (toolName.isEmpty()) { toolName = getMethodName(); } + + // Extract the new title attribute + String title = extractAnnotationValue("io.jooby.annotation.mcp.McpTool", "title"); + var titleArg = + title.isEmpty() + ? (methodSummary.isEmpty() ? "null" : string(methodSummary)) + : string(title); + String description = extractAnnotationValue("io.jooby.annotation.mcp.McpTool", "description"); if (description.isEmpty()) { - description = methodSummaryAndDescription; + description = methodDescription; } if (kt) { @@ -147,6 +156,7 @@ public List generateMcpDefinitionMethod(boolean kt) { indent(6), "var req = schema.putArray(", string("required"), ")", semicolon(kt))); } + // --- PARAMETER SCHEMA GENERATION --- for (var param : getParameters(true)) { var type = param.getType().getRawType().toString(); if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange") @@ -154,14 +164,11 @@ public List generateMcpDefinitionMethod(boolean kt) { || type.equals("io.jooby.Context")) continue; var mcpName = param.getMcpName(); - - // 1. Extract the description from the @McpParam annotation var paramDescription = param.getMcpDescription(); if (paramDescription == null) { paramDescription = method.map(it -> it.getParameterDoc(param.getName())).orElse(""); } - // 2. Generate the schema and inject the description directly if (kt) { buffer.add( statement( @@ -238,6 +245,7 @@ public List generateMcpDefinitionMethod(boolean kt) { } } + // --- OUTPUT SCHEMA GENERATION --- String returnTypeStr = getReturnType().getRawType().toString(); boolean generateOutputSchema = hasOutputSchema(); String outputSchemaArg = "null"; @@ -283,30 +291,83 @@ public List generateMcpDefinitionMethod(boolean kt) { } } + // --- NESTED ANNOTATION EXTRACTION --- + String annotationsArg = "null"; + var toolAnnotation = parseToolAnnotation(); + if (kt) { + if (toolAnnotation != null) { + annotationsArg = "annotations"; + buffer.add( + statement( + indent(6), + "val annotations = io.modelcontextprotocol.spec.McpSchema.ToolAnnotations(", + methodSummaryAndDescription.isEmpty() + ? "null" + : string(methodSummaryAndDescription), + ", ", + toolAnnotation.readOnlyHint(), + ", ", + toolAnnotation.destructiveHint(), + ", ", + toolAnnotation.idempotentHint(), + ", ", + toolAnnotation.openWorldHint(), + ", null)")); + } + buffer.add( statement( indent(6), "return io.modelcontextprotocol.spec.McpSchema.Tool(", string(toolName), - ", null, ", + ", ", + titleArg, + ", ", string(description), ", mapper.treeToValue(schema," + " io.modelcontextprotocol.spec.McpSchema.JsonSchema::class.java), ", outputSchemaArg, - ", null, null)")); + ", ", + annotationsArg, + ", null)")); } else { + if (toolAnnotation != null) { + annotationsArg = "annotations"; + buffer.add( + statement( + indent(6), + "var annotations = new io.modelcontextprotocol.spec.McpSchema.ToolAnnotations(", + methodSummaryAndDescription.isEmpty() + ? "null" + : string(methodSummaryAndDescription), + ", ", + toolAnnotation.readOnlyHint(), + ", ", + toolAnnotation.destructiveHint(), + ", ", + toolAnnotation.idempotentHint(), + ", ", + toolAnnotation.openWorldHint(), + ", null)", + semicolon(kt))); + } + buffer.add( statement( indent(6), "return new io.modelcontextprotocol.spec.McpSchema.Tool(", string(toolName), - ", null, ", + ", ", + titleArg, + ", ", string(description), ", mapper.treeToValue(schema," + " io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), ", outputSchemaArg, - ", null, null)", + ", ", + annotationsArg, + ", null)", semicolon(kt))); } buffer.add(statement(indent(4), "}\n")); @@ -944,5 +1005,38 @@ private McpAnnotation parseResourceAnnotation() { return new McpAnnotation(String.join(", ", audienceList), lastMod.toString(), priority); } + private McpToolAnnotation parseToolAnnotation() { + String rawAnnotations = + extractAnnotationValue("io.jooby.annotation.mcp.McpTool", "annotations"); + + if (rawAnnotations.isEmpty()) { + return null; // APT didn't find explicitly declared annotations + } + + // Default values matching the @McpAnnotations interface + String readOnlyHint = "false"; + String destructiveHint = "true"; + String idempotentHint = "false"; + String openWorldHint = "true"; + + if (rawAnnotations.contains("readOnlyHint=")) { + readOnlyHint = rawAnnotations.replaceAll(".*readOnlyHint=(true|false).*", "$1"); + } + if (rawAnnotations.contains("destructiveHint=")) { + destructiveHint = rawAnnotations.replaceAll(".*destructiveHint=(true|false).*", "$1"); + } + if (rawAnnotations.contains("idempotentHint=")) { + idempotentHint = rawAnnotations.replaceAll(".*idempotentHint=(true|false).*", "$1"); + } + if (rawAnnotations.contains("openWorldHint=")) { + openWorldHint = rawAnnotations.replaceAll(".*openWorldHint=(true|false).*", "$1"); + } + + return new McpToolAnnotation(readOnlyHint, destructiveHint, idempotentHint, openWorldHint); + } + + private record McpToolAnnotation( + String readOnlyHint, String destructiveHint, String idempotentHint, String openWorldHint) {} + private record McpAnnotation(String audience, String lastModified, String priority) {} } diff --git a/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java index 0961b1be96..19b1a85976 100644 --- a/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java +++ b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java @@ -20,7 +20,7 @@ public class ExampleServer { * @param a 1st number * @return sum of the two numbers */ - @McpTool(name = "calculator") + @McpTool(name = "calculator", annotations = @McpTool.McpAnnotations(readOnlyHint = true)) public int add(@McpParam(name = "a") int a, @McpParam(description = "2nd number") int b) { return a + b; } @@ -45,12 +45,11 @@ public String reviewCode( uri = "file:///logs/app.log", name = "Application Logs", size = 1024, - annotations = { - @McpResource.McpAnnotations( - audience = McpSchema.Role.USER, - lastModified = "1", - priority = 1.5) - }) + annotations = + @McpResource.McpAnnotations( + audience = McpSchema.Role.USER, + lastModified = "1", + priority = 1.5)) public String getLogs() { return "Log content here..."; } diff --git a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java index f525796758..42967ee44a 100644 --- a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java +++ b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java @@ -115,7 +115,8 @@ private io.modelcontextprotocol.spec.McpSchema.Tool addToolSpec(tools.jackson.da schema_b.put("description", "2nd number"); props.set("b", schema_b); req.add("b"); - return new io.modelcontextprotocol.spec.McpSchema.Tool("calculator", null, "Add two numbers.A simple calculator.", mapper.treeToValue(schema, io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), null, null, null); + var annotations = new io.modelcontextprotocol.spec.McpSchema.ToolAnnotations("Add two numbers.A simple calculator.", true, true, false, true, null); + return new io.modelcontextprotocol.spec.McpSchema.Tool("calculator", "Add two numbers.", "A simple calculator.", mapper.treeToValue(schema, io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), null, annotations, null); } private io.modelcontextprotocol.spec.McpSchema.CallToolResult add(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.CallToolRequest req) { diff --git a/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpResource.java b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpResource.java index 70d13a7bd5..4ea5d3cd2a 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpResource.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpResource.java @@ -56,7 +56,11 @@ int size() default -1; /** Optional MCP metadata annotations for this resource. */ - McpAnnotations[] annotations() default {}; + McpAnnotations annotations() default + @McpAnnotations( + audience = {McpSchema.Role.USER}, + lastModified = "", + priority = 0.5); @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.ANNOTATION_TYPE) diff --git a/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpTool.java b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpTool.java index a16a276449..3671e31c74 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpTool.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpTool.java @@ -21,10 +21,60 @@ */ String name() default ""; + /** + * Intended for UI and end-user contexts — optimized to be human-readable and easily understood, + * even by those unfamiliar with domain-specific terminology. If not provided, the name should be + * used for display (except for Tool, where annotations.title should be given precedence over + * using name, if present). + */ + String title() default ""; + /** * A description of what the tool does. Highly recommended for LLM usage. * * @return Tool description. */ String description() default ""; + + /** Additional hints for clients. */ + McpAnnotations annotations() default @McpAnnotations; + + /** + * Additional properties describing a Tool to clients. + * + *

All properties in ToolAnnotations are hints. They are not guaranteed to provide a faithful + * description of tool behavior (including descriptive properties like title). + * + *

Clients should never make tool use decisions based on ToolAnnotations received from + * untrusted servers. + */ + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.ANNOTATION_TYPE) + @interface McpAnnotations { + /** If true, the tool does not modify its environment. */ + boolean readOnlyHint() default false; + + /** + * If true, the tool may perform destructive updates to its environment. If false, the tool + * performs only additive updates. + * + *

(This property is meaningful only when readOnlyHint == false) + */ + boolean destructiveHint() default true; + + /** + * If true, calling the tool repeatedly with the same arguments will have no additional effect + * on the its environment. + * + *

(This property is meaningful only when readOnlyHint == false) + */ + boolean idempotentHint() default false; + + /** + * If true, this tool may interact with an “open world” of external entities. If false, the + * tool’s domain of interaction is closed. For example, the world of a web search tool is open, + * whereas that of a memory tool is not. + */ + boolean openWorldHint() default true; + } } From c817193d984e997ada7efc99a832c9613abdf256 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 28 Mar 2026 18:41:04 -0300 Subject: [PATCH 25/37] - split mcp definition/spec into their own method --- .../java/io/jooby/internal/apt/CodeBlock.java | 4 +- .../java/io/jooby/internal/apt/McpRoute.java | 969 +++++++++--------- .../test/java/tests/i3830/ExampleServer.java | 3 +- .../src/test/java/tests/i3830/Issue3830.java | 2 +- .../io/jooby/annotation/mcp/McpPrompt.java | 3 + 5 files changed, 507 insertions(+), 474 deletions(-) diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/CodeBlock.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/CodeBlock.java index 3fa2a04857..0bf5361a73 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/CodeBlock.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/CodeBlock.java @@ -24,7 +24,9 @@ public static String of(CharSequence... sequence) { } public static CharSequence string(CharSequence value) { - return "\"" + EscapeUtils.escapeJava(value) + "\""; + return value == null || "null".equals(value.toString()) + ? "null" + : "\"" + EscapeUtils.escapeJava(value) + "\""; } public static CharSequence clazz(boolean kt) { diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java index ec0e3f986f..93247859fb 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java @@ -11,10 +11,12 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Optional; import javax.lang.model.element.ExecutableElement; import io.jooby.javadoc.JavaDocNode; +import io.jooby.javadoc.MethodDoc; public class McpRoute extends WebRoute { private boolean isMcpTool = false; @@ -79,573 +81,598 @@ public boolean isMcpCompletion() { private String extractAnnotationValue(String annotationName, String attribute) { var annotation = AnnotationSupport.findAnnotationByName(method, annotationName); - if (annotation == null) return ""; + if (annotation == null) return null; return AnnotationSupport.findAnnotationValue(annotation, attribute::equals).stream() .findFirst() - .orElse(""); + .filter(it -> !it.isEmpty()) + .orElse(null); } public List generateMcpDefinitionMethod(boolean kt) { + if (isMcpTool()) { + return generateToolDefinition(kt); + } else if (isMcpPrompt()) { + return generatePromptDefinition(kt); + } else if (isMcpResource() || isMcpResourceTemplate()) { + return generateResourceDefinition(kt); + } + // unreachable + return List.of(); + } + + private List generateResourceDefinition(boolean kt) { List buffer = new ArrayList<>(); - var method = router.getMethodDoc(getMethodName(), getRawParameterTypes(false, kt, true)); - var methodSummary = method.map(JavaDocNode::getSummary).orElse(""); - var methodDescription = method.map(JavaDocNode::getDescription).orElse(""); - var methodSummaryAndDescription = method.map(JavaDocNode::getFullDescription).orElse(""); + var method = getMethodDoc(kt); + var methodSummary = method.map(JavaDocNode::getSummary).orElse(null); + var methodDescription = method.map(JavaDocNode::getDescription).orElse(null); + + var uri = extractAnnotationValue("io.jooby.annotation.mcp.McpResource", "uri"); + var name = + Optional.ofNullable(extractAnnotationValue("io.jooby.annotation.mcp.McpResource", "name")) + .orElse(getMethodName()); + + var title = + Optional.ofNullable(extractAnnotationValue("io.jooby.annotation.mcp.McpResource", "title")) + .orElse(methodSummary); + var description = + Optional.ofNullable( + extractAnnotationValue("io.jooby.annotation.mcp.McpResource", "description")) + .orElse(methodDescription); + var mimeType = extractAnnotationValue("io.jooby.annotation.mcp.McpResource", "mimeType"); + var sizeStr = extractAnnotationValue("io.jooby.annotation.mcp.McpResource", "size"); + + // Prepare standard arguments safely + var titleArg = string(title); + var descriptionArg = string(description); + var mimeTypeArg = + mimeType == null + ? of( + "io.jooby.MediaType.byFileExtension(", + string(uri), + ", ", + string("text/plain"), + ").getValue()") + : string(mimeType); + String sizeArg = (sizeStr == null || sizeStr.equals("-1")) ? "null" : sizeStr + "L"; - if (isMcpTool()) { - String toolName = extractAnnotationValue("io.jooby.annotation.mcp.McpTool", "name"); - if (toolName.isEmpty()) { - toolName = getMethodName(); - } + // --- NESTED ANNOTATION EXTRACTION --- + String annotationsArg = "null"; + var annotation = parseResourceAnnotation(); - // Extract the new title attribute - String title = extractAnnotationValue("io.jooby.annotation.mcp.McpTool", "title"); - var titleArg = - title.isEmpty() - ? (methodSummary.isEmpty() ? "null" : string(methodSummary)) - : string(title); + var isTemplate = isMcpResourceTemplate(); + var specType = isTemplate ? "ResourceTemplate" : "Resource"; - String description = extractAnnotationValue("io.jooby.annotation.mcp.McpTool", "description"); - if (description.isEmpty()) { - description = methodDescription; - } + if (kt) { + buffer.add( + statement( + indent(4), + "private fun ", + getMethodName(), + specType, + "Spec(): io.modelcontextprotocol.spec.McpSchema.", + specType, + " {")); - if (kt) { - buffer.add( - statement( - indent(4), - "private fun ", - getMethodName(), - "ToolSpec(mapper: tools.jackson.databind.ObjectMapper, schemaGenerator:" - + " com.github.victools.jsonschema.generator.SchemaGenerator):" - + " io.modelcontextprotocol.spec.McpSchema.Tool {")); - buffer.add(statement(indent(6), "val schema = mapper.createObjectNode()")); - buffer.add( - statement(indent(6), "schema.put(", string("type"), ", ", string("object"), ")")); - buffer.add( - statement(indent(6), "val props = schema.putObject(", string("properties"), ")")); - buffer.add(statement(indent(6), "val req = schema.putArray(", string("required"), ")")); - } else { - buffer.add( - statement( - indent(4), - "private io.modelcontextprotocol.spec.McpSchema.Tool ", - getMethodName(), - "ToolSpec(tools.jackson.databind.ObjectMapper mapper," - + " com.github.victools.jsonschema.generator.SchemaGenerator" - + " schemaGenerator) {")); - buffer.add(statement(indent(6), "var schema = mapper.createObjectNode()", semicolon(kt))); + // Build the Kotlin ResourceAnnotations object if present + if (annotation != null) { + buffer.add(statement(indent(6), "val audience = ", "listOf(", annotation.audience, "")); buffer.add( statement( indent(6), - "schema.put(", - string("type"), + "val annotations = io.modelcontextprotocol.spec.McpSchema.Annotations(audience, ", + annotation.priority, ", ", - string("object"), - ")", - semicolon(kt))); - buffer.add( - statement( - indent(6), - "var props = schema.putObject(", - string("properties"), - ")", - semicolon(kt))); - buffer.add( - statement( - indent(6), "var req = schema.putArray(", string("required"), ")", semicolon(kt))); - } - - // --- PARAMETER SCHEMA GENERATION --- - for (var param : getParameters(true)) { - var type = param.getType().getRawType().toString(); - if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange") - || type.equals("io.modelcontextprotocol.common.McpTransportContext") - || type.equals("io.jooby.Context")) continue; - - var mcpName = param.getMcpName(); - var paramDescription = param.getMcpDescription(); - if (paramDescription == null) { - paramDescription = method.map(it -> it.getParameterDoc(param.getName())).orElse(""); - } - - if (kt) { - buffer.add( - statement( - indent(6), - "val schema_", - mcpName, - " = schemaGenerator.generateSchema(", - type, - "::class.java)")); - - if (!paramDescription.isEmpty()) { - buffer.add( - statement( - indent(6), - "schema_", - mcpName, - ".put(", - string("description"), - ", ", - string(paramDescription), - ")")); - } - - buffer.add( - statement( - indent(6), - "props.set(", - string(mcpName), - ", schema_", - mcpName, - ")")); - - if (!param.isNullable(kt)) { - buffer.add(statement(indent(6), "req.add(", string(mcpName), ")")); - } - } else { - buffer.add( - statement( - indent(6), - "var schema_", - mcpName, - " = schemaGenerator.generateSchema(", - type, - ".class)", - semicolon(kt))); - - if (!paramDescription.isEmpty()) { - buffer.add( - statement( - indent(6), - "schema_", - mcpName, - ".put(", - string("description"), - ", ", - string(paramDescription), - ")", - semicolon(kt))); - } - - buffer.add( - statement( - indent(6), - "props.set(", - string(mcpName), - ", schema_", - mcpName, - ")", - semicolon(kt))); - - if (!param.isNullable(kt)) { - buffer.add(statement(indent(6), "req.add(", string(mcpName), ")", semicolon(kt))); - } - } - } - - // --- OUTPUT SCHEMA GENERATION --- - String returnTypeStr = getReturnType().getRawType().toString(); - boolean generateOutputSchema = hasOutputSchema(); - String outputSchemaArg = "null"; - - if (generateOutputSchema) { - outputSchemaArg = getMethodName() + "OutputSchema"; - if (kt) { - buffer.add( - statement( - indent(6), - "val ", - outputSchemaArg, - "Node = schemaGenerator.generateSchema(", - returnTypeStr, - "::class.java)")); - buffer.add( - statement( - indent(6), - "val ", - outputSchemaArg, - " = mapper.convertValue(", - outputSchemaArg, - "Node, Map::class.java) as Map")); - } else { - buffer.add( - statement( - indent(6), - "var ", - outputSchemaArg, - "Node = schemaGenerator.generateSchema(", - returnTypeStr, - ".class)", - semicolon(kt))); - buffer.add( - statement( - indent(6), - "var ", - outputSchemaArg, - " = mapper.convertValue(", - outputSchemaArg, - "Node, java.util.Map.class)", - semicolon(kt))); - } + annotation.lastModified, + ")")); } - // --- NESTED ANNOTATION EXTRACTION --- - String annotationsArg = "null"; - var toolAnnotation = parseToolAnnotation(); - - if (kt) { - if (toolAnnotation != null) { - annotationsArg = "annotations"; - buffer.add( - statement( - indent(6), - "val annotations = io.modelcontextprotocol.spec.McpSchema.ToolAnnotations(", - methodSummaryAndDescription.isEmpty() - ? "null" - : string(methodSummaryAndDescription), - ", ", - toolAnnotation.readOnlyHint(), - ", ", - toolAnnotation.destructiveHint(), - ", ", - toolAnnotation.idempotentHint(), - ", ", - toolAnnotation.openWorldHint(), - ", null)")); - } - + if (!isTemplate) { buffer.add( statement( indent(6), - "return io.modelcontextprotocol.spec.McpSchema.Tool(", - string(toolName), + "return io.modelcontextprotocol.spec.McpSchema.Resource(", + string(uri), + ", ", + string(name), ", ", titleArg, ", ", - string(description), - ", mapper.treeToValue(schema," - + " io.modelcontextprotocol.spec.McpSchema.JsonSchema::class.java), ", - outputSchemaArg, + descriptionArg, + ", ", + mimeTypeArg, + ", ", + sizeArg, ", ", annotationsArg, ", null)")); } else { - if (toolAnnotation != null) { - annotationsArg = "annotations"; - buffer.add( - statement( - indent(6), - "var annotations = new io.modelcontextprotocol.spec.McpSchema.ToolAnnotations(", - methodSummaryAndDescription.isEmpty() - ? "null" - : string(methodSummaryAndDescription), - ", ", - toolAnnotation.readOnlyHint(), - ", ", - toolAnnotation.destructiveHint(), - ", ", - toolAnnotation.idempotentHint(), - ", ", - toolAnnotation.openWorldHint(), - ", null)", - semicolon(kt))); - } - buffer.add( statement( indent(6), - "return new io.modelcontextprotocol.spec.McpSchema.Tool(", - string(toolName), + "return io.modelcontextprotocol.spec.McpSchema.ResourceTemplate(", + string(uri), + ", ", + string(name), ", ", titleArg, ", ", - string(description), - ", mapper.treeToValue(schema," - + " io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), ", - outputSchemaArg, + descriptionArg, + ", ", + mimeTypeArg, ", ", annotationsArg, - ", null)", - semicolon(kt))); + ", null)")); } buffer.add(statement(indent(4), "}\n")); - } else if (isMcpPrompt()) { - String promptName = extractAnnotationValue("io.jooby.annotation.mcp.McpPrompt", "name"); - if (promptName.isEmpty()) { - promptName = getMethodName(); - } - String description = - extractAnnotationValue("io.jooby.annotation.mcp.McpPrompt", "description"); - if (description.isEmpty()) { - description = methodSummaryAndDescription; - } + } else { + buffer.add( + statement( + indent(4), + "private io.modelcontextprotocol.spec.McpSchema.", + specType, + " ", + getMethodName(), + specType, + "Spec() {")); + + // Build the Java ResourceAnnotations object if present + if (annotation != null) { + annotationsArg = "annotations"; - if (kt) { buffer.add( statement( - indent(4), - "private fun ", - getMethodName(), - "PromptSpec(): io.modelcontextprotocol.spec.McpSchema.Prompt {")); + indent(6), + "var audience = ", + "java.util.List.of(", + annotation.audience, + ")", + semicolon(kt))); buffer.add( statement( indent(6), - "val args =" - + " mutableListOf()")); - } else { + "var annotations = new" + + " io.modelcontextprotocol.spec.McpSchema.Annotations(audience, ", + annotation.priority, + "D, ", + annotation.lastModified, + ")", + semicolon(kt))); + } + + if (!isTemplate) { buffer.add( statement( - indent(4), - "private io.modelcontextprotocol.spec.McpSchema.Prompt ", - getMethodName(), - "PromptSpec() {")); + indent(6), + "return new io.modelcontextprotocol.spec.McpSchema.Resource(", + string(uri), + ", ", + string(name), + ", ", + titleArg, + ", ", + descriptionArg, + ", ", + mimeTypeArg, + ", ", + sizeArg, + ", ", + annotationsArg, + ", null)", + semicolon(kt))); + } else { buffer.add( statement( indent(6), - "var args = new" - + " java.util.ArrayList()", + "return new io.modelcontextprotocol.spec.McpSchema.ResourceTemplate(", + string(uri), + ", ", + string(name), + ", ", + titleArg, + ", ", + descriptionArg, + ", ", + mimeTypeArg, + ", ", + annotationsArg, + ", null)", semicolon(kt))); } + buffer.add(statement(indent(4), "}\n")); + } + return buffer; + } - for (var param : getParameters(true)) { - var type = param.getType().getRawType().toString(); - if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange") - || type.equals("io.modelcontextprotocol.common.McpTransportContext") - || type.equals("io.jooby.Context")) continue; + private List generatePromptDefinition(boolean kt) { + List buffer = new ArrayList<>(); + var method = getMethodDoc(kt); + var methodSummary = method.map(JavaDocNode::getSummary).orElse(null); + var methodDescription = method.map(JavaDocNode::getDescription).orElse(null); + + String promptName = + Optional.ofNullable(extractAnnotationValue("io.jooby.annotation.mcp.McpPrompt", "name")) + .orElse(getMethodName()); + + String title = + Optional.ofNullable(extractAnnotationValue("io.jooby.annotation.mcp.McpPrompt", "title")) + .orElse(methodSummary); + String description = + Optional.ofNullable( + extractAnnotationValue("io.jooby.annotation.mcp.McpPrompt", "description")) + .orElse(methodDescription); - var mcpName = param.getMcpName(); - var isRequired = !param.isNullable(kt); + if (kt) { + buffer.add( + statement( + indent(4), + "private fun ", + getMethodName(), + "PromptSpec(): io.modelcontextprotocol.spec.McpSchema.Prompt {")); + buffer.add( + statement( + indent(6), + "val args =" + + " mutableListOf()")); + } else { + buffer.add( + statement( + indent(4), + "private io.modelcontextprotocol.spec.McpSchema.Prompt ", + getMethodName(), + "PromptSpec() {")); + buffer.add( + statement( + indent(6), + "var args = new" + + " java.util.ArrayList()", + semicolon(kt))); + } - if (kt) { - buffer.add( - statement( - indent(6), - "args.add(io.modelcontextprotocol.spec.McpSchema.PromptArgument(", - string(mcpName), - ", null, ", - String.valueOf(isRequired), - "))")); - } else { - buffer.add( - statement( - indent(6), - "args.add(new io.modelcontextprotocol.spec.McpSchema.PromptArgument(", - string(mcpName), - ", null, ", - String.valueOf(isRequired), - "))", - semicolon(kt))); - } - } + for (var param : getParameters(true)) { + var type = param.getType().getRawType().toString(); + if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange") + || type.equals("io.modelcontextprotocol.common.McpTransportContext") + || type.equals("io.jooby.Context")) continue; + + var mcpName = param.getMcpName(); + var isRequired = !param.isNullable(kt); if (kt) { buffer.add( statement( indent(6), - "return io.modelcontextprotocol.spec.McpSchema.Prompt(", - string(promptName), + "args.add(io.modelcontextprotocol.spec.McpSchema.PromptArgument(", + string(mcpName), ", null, ", - string(description), - ", args)")); + String.valueOf(isRequired), + "))")); } else { buffer.add( statement( indent(6), - "return new io.modelcontextprotocol.spec.McpSchema.Prompt(", - string(promptName), + "args.add(new io.modelcontextprotocol.spec.McpSchema.PromptArgument(", + string(mcpName), ", null, ", - string(description), - ", args)", + String.valueOf(isRequired), + "))", semicolon(kt))); } - buffer.add(statement(indent(4), "}\n")); + } - } else if (isMcpResource() || isMcpResourceTemplate()) { - var uri = extractAnnotationValue("io.jooby.annotation.mcp.McpResource", "uri"); - var name = extractAnnotationValue("io.jooby.annotation.mcp.McpResource", "name"); - if (name.isEmpty()) { - name = getMethodName(); - } + if (kt) { + buffer.add( + statement( + indent(6), + "return io.modelcontextprotocol.spec.McpSchema.Prompt(", + string(promptName), + ", ", + string(title), + ", ", + string(description), + ", args)")); + } else { + buffer.add( + statement( + indent(6), + "return new io.modelcontextprotocol.spec.McpSchema.Prompt(", + string(promptName), + ", ", + string(title), + ", ", + string(description), + ", args)", + semicolon(kt))); + } + buffer.add(statement(indent(4), "}\n")); + return buffer; + } - var title = extractAnnotationValue("io.jooby.annotation.mcp.McpResource", "title"); - var description = - extractAnnotationValue("io.jooby.annotation.mcp.McpResource", "description"); - var mimeType = extractAnnotationValue("io.jooby.annotation.mcp.McpResource", "mimeType"); - var sizeStr = extractAnnotationValue("io.jooby.annotation.mcp.McpResource", "size"); - - // Prepare standard arguments safely - var titleArg = - title.isEmpty() - ? (methodSummary.isEmpty() ? "null" : string(methodSummary)) - : string(title); - var descriptionArg = - description.isEmpty() - ? (methodDescription.isEmpty() ? "null" : string(methodDescription)) - : string(description); - var mimeTypeArg = - mimeType.isEmpty() - ? of( - "io.jooby.MediaType.byFileExtension(", - string(uri), - ", ", - string("text/plain"), - ").getValue()") - : string(mimeType); - String sizeArg = (sizeStr.isEmpty() || sizeStr.equals("-1")) ? "null" : sizeStr + "L"; + private List generateToolDefinition(boolean kt) { + var buffer = new ArrayList(); + var method = getMethodDoc(kt); + var methodSummary = method.map(JavaDocNode::getSummary).orElse(null); + var methodDescription = method.map(JavaDocNode::getDescription).orElse(null); + var methodSummaryAndDescription = method.map(JavaDocNode::getFullDescription).orElse(null); + String toolName = + Optional.ofNullable(extractAnnotationValue("io.jooby.annotation.mcp.McpTool", "name")) + .orElse(getMethodName()); + + // Extract the new title attribute + String title = extractAnnotationValue("io.jooby.annotation.mcp.McpTool", "title"); + var titleArg = string(Optional.ofNullable(title).orElse(methodSummary)); + + String description = + Optional.ofNullable( + extractAnnotationValue("io.jooby.annotation.mcp.McpTool", "description")) + .orElse(methodDescription); - // --- NESTED ANNOTATION EXTRACTION --- - String annotationsArg = "null"; - var annotation = parseResourceAnnotation(); + if (kt) { + buffer.add( + statement( + indent(4), + "private fun ", + getMethodName(), + "ToolSpec(mapper: tools.jackson.databind.ObjectMapper, schemaGenerator:" + + " com.github.victools.jsonschema.generator.SchemaGenerator):" + + " io.modelcontextprotocol.spec.McpSchema.Tool {")); + buffer.add(statement(indent(6), "val schema = mapper.createObjectNode()")); + buffer.add(statement(indent(6), "schema.put(", string("type"), ", ", string("object"), ")")); + buffer.add(statement(indent(6), "val props = schema.putObject(", string("properties"), ")")); + buffer.add(statement(indent(6), "val req = schema.putArray(", string("required"), ")")); + } else { + buffer.add( + statement( + indent(4), + "private io.modelcontextprotocol.spec.McpSchema.Tool ", + getMethodName(), + "ToolSpec(tools.jackson.databind.ObjectMapper mapper," + + " com.github.victools.jsonschema.generator.SchemaGenerator" + + " schemaGenerator) {")); + buffer.add(statement(indent(6), "var schema = mapper.createObjectNode()", semicolon(kt))); + buffer.add( + statement( + indent(6), + "schema.put(", + string("type"), + ", ", + string("object"), + ")", + semicolon(kt))); + buffer.add( + statement( + indent(6), + "var props = schema.putObject(", + string("properties"), + ")", + semicolon(kt))); + buffer.add( + statement( + indent(6), "var req = schema.putArray(", string("required"), ")", semicolon(kt))); + } + + // --- PARAMETER SCHEMA GENERATION --- + for (var param : getParameters(true)) { + var type = param.getType().getRawType().toString(); + if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange") + || type.equals("io.modelcontextprotocol.common.McpTransportContext") + || type.equals("io.jooby.Context")) continue; - var isTemplate = isMcpResourceTemplate(); - var specType = isTemplate ? "ResourceTemplate" : "Resource"; + var mcpName = param.getMcpName(); + var paramDescription = param.getMcpDescription(); + if (paramDescription == null) { + paramDescription = method.map(it -> it.getParameterDoc(param.getName())).orElse(""); + } if (kt) { buffer.add( statement( - indent(4), - "private fun ", - getMethodName(), - specType, - "Spec(): io.modelcontextprotocol.spec.McpSchema.", - specType, - " {")); - - // Build the Kotlin ResourceAnnotations object if present - if (annotation != null) { - buffer.add(statement(indent(6), "val audience = ", "listOf(", annotation.audience, "")); + indent(6), + "val schema_", + mcpName, + " = schemaGenerator.generateSchema(", + type, + "::class.java)")); + + if (!paramDescription.isEmpty()) { buffer.add( statement( indent(6), - "val annotations = io.modelcontextprotocol.spec.McpSchema.Annotations(audience, ", - annotation.priority, + "schema_", + mcpName, + ".put(", + string("description"), ", ", - annotation.lastModified, + string(paramDescription), ")")); } - if (!isTemplate) { - buffer.add( - statement( - indent(6), - "return io.modelcontextprotocol.spec.McpSchema.Resource(", - string(uri), - ", ", - string(name), - ", ", - titleArg, - ", ", - descriptionArg, - ", ", - mimeTypeArg, - ", ", - sizeArg, - ", ", - annotationsArg, - ", null)")); - } else { - buffer.add( - statement( - indent(6), - "return io.modelcontextprotocol.spec.McpSchema.ResourceTemplate(", - string(uri), - ", ", - string(name), - ", ", - titleArg, - ", ", - descriptionArg, - ", ", - mimeTypeArg, - ", ", - annotationsArg, - ", null)")); - } - buffer.add(statement(indent(4), "}\n")); + buffer.add( + statement( + indent(6), + "props.set(", + string(mcpName), + ", schema_", + mcpName, + ")")); + if (!param.isNullable(kt)) { + buffer.add(statement(indent(6), "req.add(", string(mcpName), ")")); + } } else { buffer.add( statement( - indent(4), - "private io.modelcontextprotocol.spec.McpSchema.", - specType, - " ", - getMethodName(), - specType, - "Spec() {")); - - // Build the Java ResourceAnnotations object if present - if (annotation != null) { - annotationsArg = "annotations"; + indent(6), + "var schema_", + mcpName, + " = schemaGenerator.generateSchema(", + type, + ".class)", + semicolon(kt))); + if (!paramDescription.isEmpty()) { buffer.add( statement( indent(6), - "var audience = ", - "java.util.List.of(", - annotation.audience, - ")", - semicolon(kt))); - buffer.add( - statement( - indent(6), - "var annotations = new" - + " io.modelcontextprotocol.spec.McpSchema.Annotations(audience, ", - annotation.priority, - "D, ", - annotation.lastModified, + "schema_", + mcpName, + ".put(", + string("description"), + ", ", + string(paramDescription), ")", semicolon(kt))); } - if (!isTemplate) { - buffer.add( - statement( - indent(6), - "return new io.modelcontextprotocol.spec.McpSchema.Resource(", - string(uri), - ", ", - string(name), - ", ", - titleArg, - ", ", - descriptionArg, - ", ", - mimeTypeArg, - ", ", - sizeArg, - ", ", - annotationsArg, - ", null)", - semicolon(kt))); - } else { - buffer.add( - statement( - indent(6), - "return new io.modelcontextprotocol.spec.McpSchema.ResourceTemplate(", - string(uri), - ", ", - string(name), - ", ", - titleArg, - ", ", - descriptionArg, - ", ", - mimeTypeArg, - ", ", - annotationsArg, - ", null)", - semicolon(kt))); + buffer.add( + statement( + indent(6), + "props.set(", + string(mcpName), + ", schema_", + mcpName, + ")", + semicolon(kt))); + + if (!param.isNullable(kt)) { + buffer.add(statement(indent(6), "req.add(", string(mcpName), ")", semicolon(kt))); } - buffer.add(statement(indent(4), "}\n")); } } + + // --- OUTPUT SCHEMA GENERATION --- + String returnTypeStr = getReturnType().getRawType().toString(); + boolean generateOutputSchema = hasOutputSchema(); + String outputSchemaArg = "null"; + + if (generateOutputSchema) { + outputSchemaArg = getMethodName() + "OutputSchema"; + if (kt) { + buffer.add( + statement( + indent(6), + "val ", + outputSchemaArg, + "Node = schemaGenerator.generateSchema(", + returnTypeStr, + "::class.java)")); + buffer.add( + statement( + indent(6), + "val ", + outputSchemaArg, + " = mapper.convertValue(", + outputSchemaArg, + "Node, Map::class.java) as Map")); + } else { + buffer.add( + statement( + indent(6), + "var ", + outputSchemaArg, + "Node = schemaGenerator.generateSchema(", + returnTypeStr, + ".class)", + semicolon(kt))); + buffer.add( + statement( + indent(6), + "var ", + outputSchemaArg, + " = mapper.convertValue(", + outputSchemaArg, + "Node, java.util.Map.class)", + semicolon(kt))); + } + } + + // --- NESTED ANNOTATION EXTRACTION --- + String annotationsArg = "null"; + var toolAnnotation = parseToolAnnotation(); + + if (kt) { + if (toolAnnotation != null) { + annotationsArg = "annotations"; + buffer.add( + statement( + indent(6), + "val annotations = io.modelcontextprotocol.spec.McpSchema.ToolAnnotations(", + methodSummaryAndDescription.isEmpty() + ? "null" + : string(methodSummaryAndDescription), + ", ", + toolAnnotation.readOnlyHint(), + ", ", + toolAnnotation.destructiveHint(), + ", ", + toolAnnotation.idempotentHint(), + ", ", + toolAnnotation.openWorldHint(), + ", null)")); + } + + buffer.add( + statement( + indent(6), + "return io.modelcontextprotocol.spec.McpSchema.Tool(", + string(toolName), + ", ", + titleArg, + ", ", + string(description), + ", mapper.treeToValue(schema," + + " io.modelcontextprotocol.spec.McpSchema.JsonSchema::class.java), ", + outputSchemaArg, + ", ", + annotationsArg, + ", null)")); + } else { + if (toolAnnotation != null) { + annotationsArg = "annotations"; + buffer.add( + statement( + indent(6), + "var annotations = new io.modelcontextprotocol.spec.McpSchema.ToolAnnotations(", + methodSummaryAndDescription.isEmpty() + ? "null" + : string(methodSummaryAndDescription), + ", ", + toolAnnotation.readOnlyHint(), + ", ", + toolAnnotation.destructiveHint(), + ", ", + toolAnnotation.idempotentHint(), + ", ", + toolAnnotation.openWorldHint(), + ", null)", + semicolon(kt))); + } + + buffer.add( + statement( + indent(6), + "return new io.modelcontextprotocol.spec.McpSchema.Tool(", + string(toolName), + ", ", + titleArg, + ", ", + string(description), + ", mapper.treeToValue(schema," + + " io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), ", + outputSchemaArg, + ", ", + annotationsArg, + ", null)", + semicolon(kt))); + } + buffer.add(statement(indent(4), "}\n")); return buffer; } + private Optional getMethodDoc(boolean kt) { + return router.getMethodDoc(getMethodName(), getRawParameterTypes(false, kt, true)); + } + public List generateMcpHandlerMethod(boolean kt) { String reqType = ""; String resType = ""; @@ -975,7 +1002,7 @@ private McpAnnotation parseResourceAnnotation() { String rawAnnotations = extractAnnotationValue("io.jooby.annotation.mcp.McpResource", "annotations"); - boolean hasAnnotations = rawAnnotations.contains("priority="); + boolean hasAnnotations = rawAnnotations != null && rawAnnotations.contains("priority="); if (!hasAnnotations) { return null; @@ -1009,8 +1036,8 @@ private McpToolAnnotation parseToolAnnotation() { String rawAnnotations = extractAnnotationValue("io.jooby.annotation.mcp.McpTool", "annotations"); - if (rawAnnotations.isEmpty()) { - return null; // APT didn't find explicitly declared annotations + if (rawAnnotations == null || rawAnnotations.isEmpty()) { + return null; } // Default values matching the @McpAnnotations interface diff --git a/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java index 19b1a85976..3524077c77 100644 --- a/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java +++ b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java @@ -28,7 +28,8 @@ public int add(@McpParam(name = "a") int a, @McpParam(description = "2nd number" // 2. Prompt /** - * Reviews the given code snippet in the context of the specified programming language. + * Review code. Reviews the given code snippet in the context of the specified programming + * language. * * @param language the programming language of the code to be reviewed * @param code the code snippet that needs to be reviewed diff --git a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java index 42967ee44a..6707f4c1e2 100644 --- a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java +++ b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java @@ -137,7 +137,7 @@ private io.modelcontextprotocol.spec.McpSchema.Prompt reviewCodePromptSpec() { var args = new java.util.ArrayList(); args.add(new io.modelcontextprotocol.spec.McpSchema.PromptArgument("language", null, false)); args.add(new io.modelcontextprotocol.spec.McpSchema.PromptArgument("code", null, false)); - return new io.modelcontextprotocol.spec.McpSchema.Prompt("review_code", null, "Reviews the given code snippet in the context of the specified programming language.", args); + return new io.modelcontextprotocol.spec.McpSchema.Prompt("review_code", "Review code.", "Reviews the given code snippet in the context of the specified programming language.", args); } private io.modelcontextprotocol.spec.McpSchema.GetPromptResult reviewCode(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.GetPromptRequest req) { diff --git a/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpPrompt.java b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpPrompt.java index d852aadfac..198dc35a3d 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpPrompt.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpPrompt.java @@ -21,6 +21,9 @@ */ String name() default ""; + /** Optional human-readable name of the prompt for display purposes. */ + String title() default ""; + /** * A description of what the prompt provides. * From bc9714cc9f8f2502b150c7947de514af8501957e Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 28 Mar 2026 18:55:53 -0300 Subject: [PATCH 26/37] - remove redunancy of kt flag --- .../java/io/jooby/apt/JoobyProcessor.java | 2 +- .../io/jooby/internal/apt/JsonRpcRouter.java | 5 +- .../java/io/jooby/internal/apt/McpRouter.java | 5 +- .../io/jooby/internal/apt/RestRouter.java | 5 +- .../io/jooby/internal/apt/TrpcRouter.java | 5 +- .../java/io/jooby/internal/apt/WebRouter.java | 8 +- .../java/tests/i3830/ExampleServerMcp_.java | 336 ++++++++++++++++++ .../src/test/java/tests/i3830/Issue3830.java | 3 +- 8 files changed, 353 insertions(+), 16 deletions(-) create mode 100644 modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java diff --git a/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java b/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java index 348e95b840..2553d7b226 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java +++ b/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java @@ -164,7 +164,7 @@ public boolean process(Set annotations, RoundEnvironment try { context.add(router); - var sourceCode = router.toSourceCode(null); + var sourceCode = router.toSourceCode(router.isKt()); var sourceLocation = router.getGeneratedFilename(); var generatedType = router.getGeneratedType(); diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRouter.java index 4679d43e0b..36dce8da9a 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRouter.java @@ -85,13 +85,12 @@ private String getJsonRpcNamespace() { } @Override - public String toSourceCode(Boolean generateKotlin) throws IOException { - boolean kt = generateKotlin == Boolean.TRUE || isKt(); + public String toSourceCode(boolean kt) throws IOException { var generateTypeName = getTargetType().getSimpleName().toString(); var generatedClass = getGeneratedType().substring(getGeneratedType().lastIndexOf('.') + 1); var namespace = getJsonRpcNamespace(); - var template = kt ? KOTLIN : JAVA; + var template = getTemplate(kt); var buffer = new StringBuilder(); context.generateStaticImports( diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java index e8ff7b0db8..b1e8a03a63 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java @@ -112,13 +112,12 @@ private String findTargetMethodName(String ref) { } @Override - public String toSourceCode(Boolean generateKotlin) throws IOException { - var kt = generateKotlin == Boolean.TRUE || isKt(); + public String toSourceCode(boolean kt) throws IOException { var generateTypeName = getTargetType().getSimpleName().toString(); var mcpClassName = getGeneratedType().substring(getGeneratedType().lastIndexOf('.') + 1); var packageName = getPackageName(); - var template = kt ? KOTLIN : JAVA; + var template = getTemplate(kt); var buffer = new StringBuilder(); context.generateStaticImports( diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRouter.java index 19ac2cfabe..8659360b3c 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRouter.java @@ -71,12 +71,11 @@ public String getGeneratedType() { } @Override - public String toSourceCode(Boolean generateKotlin) throws IOException { - boolean kt = generateKotlin == Boolean.TRUE || isKt(); + public String toSourceCode(boolean kt) throws IOException { var generateTypeName = getTargetType().getSimpleName().toString(); var generatedClass = getGeneratedType().substring(getGeneratedType().lastIndexOf('.') + 1); - var template = kt ? KOTLIN : JAVA; + var template = getTemplate(kt); var suspended = getRoutes().stream().filter(WebRoute::isSuspendFun).toList(); var noSuspended = getRoutes().stream().filter(it -> !it.isSuspendFun()).toList(); var buffer = new StringBuilder(); diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRouter.java index 9c1ae474e6..e002c35b78 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRouter.java @@ -77,12 +77,11 @@ public String getGeneratedType() { } @Override - public String toSourceCode(Boolean generateKotlin) throws IOException { - boolean kt = generateKotlin == Boolean.TRUE || isKt(); + public String toSourceCode(boolean kt) throws IOException { var generateTypeName = getTargetType().getSimpleName().toString(); var generatedClass = getGeneratedType().substring(getGeneratedType().lastIndexOf('.') + 1); - var template = kt ? KOTLIN : JAVA; + var template = getTemplate(kt); var buffer = new StringBuilder(); context.generateStaticImports( diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRouter.java index 411c0a3981..88afba27e6 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRouter.java @@ -36,7 +36,7 @@ public class ${generatedClassName} implements ${implements} { } public ${generatedClassName}(io.jooby.SneakyThrows.Supplier<${className}> provider) { - setup(ctx -> (${className}) provider.get()); + setup(ctx -> provider.get()); } public ${generatedClassName}(io.jooby.SneakyThrows.Function, ${className}> provider) { @@ -87,7 +87,7 @@ public WebRouter(MvcContext context, TypeElement clazz) { public abstract String getGeneratedType(); - public abstract String toSourceCode(Boolean generateKotlin) throws IOException; + public abstract String toSourceCode(boolean kt) throws IOException; public String getGeneratedFilename() { return getGeneratedType().replace('.', '/') + (isKt() ? ".kt" : ".java"); @@ -128,6 +128,10 @@ public boolean hasBeanValidation() { return getRoutes().stream().anyMatch(WebRoute::hasBeanValidation); } + public String getTemplate(boolean kt) { + return kt ? KOTLIN : JAVA; + } + protected StringBuilder trimr(StringBuilder buffer) { var i = buffer.length() - 1; while (i > 0 && Character.isWhitespace(buffer.charAt(i))) { diff --git a/modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java new file mode 100644 index 0000000000..5b09e8feb7 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java @@ -0,0 +1,336 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.i3830; + +@io.jooby.annotation.Generated(ExampleServer.class) +public class ExampleServerMcp_ implements io.jooby.mcp.McpService { + protected java.util.function.Function factory; + + public ExampleServerMcp_() { + this(io.jooby.SneakyThrows.singleton(ExampleServer::new)); + } + + public ExampleServerMcp_(ExampleServer instance) { + setup(ctx -> instance); + } + + public ExampleServerMcp_(io.jooby.SneakyThrows.Supplier provider) { + setup(ctx -> (ExampleServer) provider.get()); + } + + public ExampleServerMcp_( + io.jooby.SneakyThrows.Function, ExampleServer> provider) { + setup(ctx -> provider.apply(ExampleServer.class)); + } + + private void setup(java.util.function.Function factory) { + this.factory = factory; + } + + private io.modelcontextprotocol.json.McpJsonMapper json; + + @Override + public void capabilities( + io.modelcontextprotocol.spec.McpSchema.ServerCapabilities.Builder capabilities) { + capabilities.tools(true); + capabilities.prompts(true); + capabilities.resources(true, true); + capabilities.completions(); + } + + @Override + public String serverKey() { + return "example-server"; + } + + @Override + public java.util.List< + io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification> + completions() { + var completions = + new java.util.ArrayList< + io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification>(); + completions.add( + new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification( + new io.modelcontextprotocol.spec.McpSchema.ResourceReference( + "file:///users/{id}/{name}/profile"), + (exchange, req) -> this.getUserProfileCompletionHandler(exchange, null, req))); + completions.add( + new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification( + new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), + (exchange, req) -> this.reviewCodeCompletionHandler(exchange, null, req))); + return completions; + } + + @Override + public java.util.List< + io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification> + statelessCompletions() { + var completions = + new java.util.ArrayList< + io.modelcontextprotocol.server.McpStatelessServerFeatures + .SyncCompletionSpecification>(); + completions.add( + new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification( + new io.modelcontextprotocol.spec.McpSchema.ResourceReference( + "file:///users/{id}/{name}/profile"), + (ctx, req) -> this.getUserProfileCompletionHandler(null, ctx, req))); + completions.add( + new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification( + new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), + (ctx, req) -> this.reviewCodeCompletionHandler(null, ctx, req))); + return completions; + } + + @Override + public void install(io.jooby.Jooby app, io.modelcontextprotocol.server.McpSyncServer server) + throws Exception { + this.json = app.getServices().require(io.modelcontextprotocol.json.McpJsonMapper.class); + var mapper = app.require(tools.jackson.databind.ObjectMapper.class); + var configBuilder = + new com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder( + com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12, + com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON); + var schemaGenerator = + new com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build()); + + server.addTool( + new io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification( + addToolSpec(mapper, schemaGenerator), + (exchange, req) -> this.add(exchange, null, req))); + server.addPrompt( + new io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification( + reviewCodePromptSpec(), (exchange, req) -> this.reviewCode(exchange, null, req))); + server.addResource( + new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification( + getLogsResourceSpec(), (exchange, req) -> this.getLogs(exchange, null, req))); + server.addResourceTemplate( + new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceTemplateSpecification( + getUserProfileResourceTemplateSpec(), + (exchange, req) -> this.getUserProfile(exchange, null, req))); + } + + @Override + public void install( + io.jooby.Jooby app, io.modelcontextprotocol.server.McpStatelessSyncServer server) + throws Exception { + this.json = app.getServices().require(io.modelcontextprotocol.json.McpJsonMapper.class); + var mapper = app.require(tools.jackson.databind.ObjectMapper.class); + var configBuilder = + new com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder( + com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12, + com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON); + var schemaGenerator = + new com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build()); + + server.addTool( + new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncToolSpecification( + addToolSpec(mapper, schemaGenerator), (ctx, req) -> this.add(null, ctx, req))); + server.addPrompt( + new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncPromptSpecification( + reviewCodePromptSpec(), (ctx, req) -> this.reviewCode(null, ctx, req))); + server.addResource( + new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceSpecification( + getLogsResourceSpec(), (ctx, req) -> this.getLogs(null, ctx, req))); + server.addResourceTemplate( + new io.modelcontextprotocol.server.McpStatelessServerFeatures + .SyncResourceTemplateSpecification( + getUserProfileResourceTemplateSpec(), + (ctx, req) -> this.getUserProfile(null, ctx, req))); + } + + private io.modelcontextprotocol.spec.McpSchema.Tool addToolSpec( + tools.jackson.databind.ObjectMapper mapper, + com.github.victools.jsonschema.generator.SchemaGenerator schemaGenerator) { + var schema = mapper.createObjectNode(); + schema.put("type", "object"); + var props = schema.putObject("properties"); + var req = schema.putArray("required"); + var schema_a = schemaGenerator.generateSchema(int.class); + schema_a.put("description", "1st number"); + props.set("a", schema_a); + req.add("a"); + var schema_b = schemaGenerator.generateSchema(int.class); + schema_b.put("description", "2nd number"); + props.set("b", schema_b); + req.add("b"); + var annotations = + new io.modelcontextprotocol.spec.McpSchema.ToolAnnotations( + "Add two numbers.A simple calculator.", true, true, false, true, null); + return new io.modelcontextprotocol.spec.McpSchema.Tool( + "calculator", + "Add two numbers.", + "A simple calculator.", + mapper.treeToValue(schema, io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), + null, + annotations, + null); + } + + private io.modelcontextprotocol.spec.McpSchema.CallToolResult add( + io.modelcontextprotocol.server.McpSyncServerExchange exchange, + io.modelcontextprotocol.common.McpTransportContext transportContext, + io.modelcontextprotocol.spec.McpSchema.CallToolRequest req) { + var ctx = + exchange != null + ? (io.jooby.Context) exchange.transportContext().get("CTX") + : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var args = + req.arguments() != null + ? req.arguments() + : java.util.Collections.emptyMap(); + var c = this.factory.apply(ctx); + var raw_a = args.get("a"); + if (raw_a == null) throw new IllegalArgumentException("Missing req param: a"); + var a = + raw_a instanceof Number ? ((Number) raw_a).intValue() : Integer.parseInt(raw_a.toString()); + var raw_b = args.get("b"); + if (raw_b == null) throw new IllegalArgumentException("Missing req param: b"); + var b = + raw_b instanceof Number ? ((Number) raw_b).intValue() : Integer.parseInt(raw_b.toString()); + var result = c.add(a, b); + return new io.jooby.mcp.McpResult(this.json).toCallToolResult(result, false); + } + + private io.modelcontextprotocol.spec.McpSchema.Prompt reviewCodePromptSpec() { + var args = new java.util.ArrayList(); + args.add(new io.modelcontextprotocol.spec.McpSchema.PromptArgument("language", null, false)); + args.add(new io.modelcontextprotocol.spec.McpSchema.PromptArgument("code", null, false)); + return new io.modelcontextprotocol.spec.McpSchema.Prompt( + "review_code", + "Review code.", + "Reviews the given code snippet in the context of the specified programming language.", + args); + } + + private io.modelcontextprotocol.spec.McpSchema.GetPromptResult reviewCode( + io.modelcontextprotocol.server.McpSyncServerExchange exchange, + io.modelcontextprotocol.common.McpTransportContext transportContext, + io.modelcontextprotocol.spec.McpSchema.GetPromptRequest req) { + var ctx = + exchange != null + ? (io.jooby.Context) exchange.transportContext().get("CTX") + : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var args = + req.arguments() != null + ? req.arguments() + : java.util.Collections.emptyMap(); + var c = this.factory.apply(ctx); + var raw_language = args.get("language"); + var language = raw_language != null ? raw_language.toString() : null; + var raw_code = args.get("code"); + var code = raw_code != null ? raw_code.toString() : null; + var result = c.reviewCode(language, code); + return new io.jooby.mcp.McpResult(this.json).toPromptResult(result); + } + + private io.modelcontextprotocol.spec.McpSchema.Resource getLogsResourceSpec() { + var audience = java.util.List.of(io.modelcontextprotocol.spec.McpSchema.Role.USER); + var annotations = new io.modelcontextprotocol.spec.McpSchema.Annotations(audience, 1.5D, "1"); + return new io.modelcontextprotocol.spec.McpSchema.Resource( + "file:///logs/app.log", + "Application Logs", + "Logs Title.", + "Log description Suspendisse potenti.", + io.jooby.MediaType.byFileExtension("file:///logs/app.log", "text/plain").getValue(), + 1024L, + annotations, + null); + } + + private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getLogs( + io.modelcontextprotocol.server.McpSyncServerExchange exchange, + io.modelcontextprotocol.common.McpTransportContext transportContext, + io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest req) { + var ctx = + exchange != null + ? (io.jooby.Context) exchange.transportContext().get("CTX") + : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var args = java.util.Collections.emptyMap(); + var c = this.factory.apply(ctx); + var result = c.getLogs(); + return new io.jooby.mcp.McpResult(this.json).toResourceResult(req.uri(), result); + } + + private io.modelcontextprotocol.spec.McpSchema.ResourceTemplate + getUserProfileResourceTemplateSpec() { + return new io.modelcontextprotocol.spec.McpSchema.ResourceTemplate( + "file:///users/{id}/{name}/profile", + "getUserProfile", + "Resource Template.", + null, + "application/json", + null, + null); + } + + private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getUserProfile( + io.modelcontextprotocol.server.McpSyncServerExchange exchange, + io.modelcontextprotocol.common.McpTransportContext transportContext, + io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest req) { + var ctx = + exchange != null + ? (io.jooby.Context) exchange.transportContext().get("CTX") + : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var uri = req.uri(); + var manager = + new io.modelcontextprotocol.util.DefaultMcpUriTemplateManager( + "file:///users/{id}/{name}/profile"); + var args = new java.util.HashMap(); + args.putAll(manager.extractVariableValues(uri)); + var c = this.factory.apply(ctx); + var raw_id = args.get("id"); + var id = raw_id != null ? raw_id.toString() : null; + var raw_name = args.get("name"); + var name = raw_name != null ? raw_name.toString() : null; + var result = c.getUserProfile(id, name); + return new io.jooby.mcp.McpResult(this.json).toResourceResult(req.uri(), result); + } + + private io.modelcontextprotocol.spec.McpSchema.CompleteResult getUserProfileCompletionHandler( + io.modelcontextprotocol.server.McpSyncServerExchange exchange, + io.modelcontextprotocol.common.McpTransportContext transportContext, + io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) { + var ctx = + exchange != null + ? (io.jooby.Context) exchange.transportContext().get("CTX") + : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var c = this.factory.apply(ctx); + var targetArg = req.argument() != null ? req.argument().name() : ""; + var typedValue = req.argument() != null ? req.argument().value() : ""; + return switch (targetArg) { + case "id" -> { + var result = c.completeUserId(typedValue); + yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); + } + case "name" -> { + var result = c.completeUserName(typedValue); + yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); + } + default -> new io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of()); + }; + } + + private io.modelcontextprotocol.spec.McpSchema.CompleteResult reviewCodeCompletionHandler( + io.modelcontextprotocol.server.McpSyncServerExchange exchange, + io.modelcontextprotocol.common.McpTransportContext transportContext, + io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) { + var ctx = + exchange != null + ? (io.jooby.Context) exchange.transportContext().get("CTX") + : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var c = this.factory.apply(ctx); + var targetArg = req.argument() != null ? req.argument().name() : ""; + var typedValue = req.argument() != null ? req.argument().value() : ""; + return switch (targetArg) { + case "language" -> { + var result = c.reviewCodelanguage(typedValue); + yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); + } + default -> new io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of()); + }; + } +} diff --git a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java index 6707f4c1e2..c20d22dc1b 100644 --- a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java +++ b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java @@ -17,6 +17,7 @@ public void shouldGenerateMcpServer() throws Exception { new ProcessorRunner(new ExampleServer()) .withMcpCode( source -> { + System.out.println(source); assertThat(source) .isEqualToNormalizingNewlines( """ @@ -35,7 +36,7 @@ public ExampleServerMcp_(ExampleServer instance) { } public ExampleServerMcp_(io.jooby.SneakyThrows.Supplier provider) { - setup(ctx -> (ExampleServer) provider.get()); + setup(ctx -> provider.get()); } public ExampleServerMcp_(io.jooby.SneakyThrows.Function, ExampleServer> provider) { From a4a115ba66f25ae7c59e53d9b02407e7b1bce687 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sun, 29 Mar 2026 10:02:39 -0300 Subject: [PATCH 27/37] feat(mcp): modularize Jackson and victools support for v2 and v3 This commit completely decouples the MCP core and APT code generator from Jackson and victools, resolving classpath conflicts between Jackson 2 and Jackson 3. It introduces dedicated integration modules to cleanly handle JSON serialization and JSON Schema generation based on the user's runtime environment. Details: * Refactored the APT generator (`McpRoute`, `McpRouter`) to build schemas using standard `java.util.LinkedHashMap` and `java.util.ArrayList` instead of Jackson's `ObjectNode` and `ArrayNode`. * Removed hardcoded `victools` configuration from the generated `install` methods. The generated router now dynamically requires the `SchemaGenerator` from the Jooby application registry. * Delegated the final schema map conversion strictly to the abstracted `McpJsonMapper` interface. * Introduced the `jooby-mcp-jackson2` module to provide bindings for Jackson 2 and `victools` 4.x. * Introduced the `jooby-mcp-jackson3` module to provide bindings for Jackson 3 and `victools` 5.x. --- .../java/io/jooby/internal/apt/McpRoute.java | 56 +++++---- .../java/io/jooby/internal/apt/McpRouter.java | 38 ++---- .../java/tests/i3830/ExampleServerMcp_.java | 36 ++---- .../src/test/java/tests/i3830/Issue3830.java | 32 +++-- modules/jooby-mcp-jackson2/pom.xml | 31 +++++ .../jooby/mcp/jackson2/McpJackson2Module.java | 33 +++++ modules/jooby-mcp-jackson3/pom.xml | 31 +++++ .../jooby/mcp/jackson3/McpJackson3Module.java | 33 +++++ modules/jooby-mcp/pom.xml | 11 +- .../src/main/java/io/jooby/mcp/McpModule.java | 15 +-- modules/pom.xml | 9 +- pom.xml | 36 ++++++ tests/pom.xml | 10 ++ .../io/jooby/i3830/CalculatorToolsTest.java | 43 ++----- .../jooby/i3830/McpExchangeInjectionTest.java | 2 + .../java/io/jooby/i3830/UserToolsTest.java | 115 +++++++++++------- 16 files changed, 332 insertions(+), 199 deletions(-) create mode 100644 modules/jooby-mcp-jackson2/pom.xml create mode 100644 modules/jooby-mcp-jackson2/src/main/java/io/jooby/mcp/jackson2/McpJackson2Module.java create mode 100644 modules/jooby-mcp-jackson3/pom.xml create mode 100644 modules/jooby-mcp-jackson3/src/main/java/io/jooby/mcp/jackson3/McpJackson3Module.java diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java index 93247859fb..30a49ccdb8 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java @@ -413,23 +413,28 @@ private List generateToolDefinition(boolean kt) { indent(4), "private fun ", getMethodName(), - "ToolSpec(mapper: tools.jackson.databind.ObjectMapper, schemaGenerator:" + "ToolSpec(schemaGenerator:" + " com.github.victools.jsonschema.generator.SchemaGenerator):" + " io.modelcontextprotocol.spec.McpSchema.Tool {")); - buffer.add(statement(indent(6), "val schema = mapper.createObjectNode()")); + buffer.add(statement(indent(6), "val schema = java.util.LinkedHashMap()")); buffer.add(statement(indent(6), "schema.put(", string("type"), ", ", string("object"), ")")); - buffer.add(statement(indent(6), "val props = schema.putObject(", string("properties"), ")")); - buffer.add(statement(indent(6), "val req = schema.putArray(", string("required"), ")")); + buffer.add(statement(indent(6), "val props = java.util.LinkedHashMap()")); + buffer.add(statement(indent(6), "schema.put(", string("properties"), ", props)")); + buffer.add(statement(indent(6), "val req = java.util.ArrayList()")); + buffer.add(statement(indent(6), "schema.put(", string("required"), ", req)")); } else { buffer.add( statement( indent(4), "private io.modelcontextprotocol.spec.McpSchema.Tool ", getMethodName(), - "ToolSpec(tools.jackson.databind.ObjectMapper mapper," - + " com.github.victools.jsonschema.generator.SchemaGenerator" + "ToolSpec(com.github.victools.jsonschema.generator.SchemaGenerator" + " schemaGenerator) {")); - buffer.add(statement(indent(6), "var schema = mapper.createObjectNode()", semicolon(kt))); + buffer.add( + statement( + indent(6), + "var schema = new java.util.LinkedHashMap()", + semicolon(kt))); buffer.add( statement( indent(6), @@ -442,13 +447,13 @@ private List generateToolDefinition(boolean kt) { buffer.add( statement( indent(6), - "var props = schema.putObject(", - string("properties"), - ")", + "var props = new java.util.LinkedHashMap()", semicolon(kt))); buffer.add( - statement( - indent(6), "var req = schema.putArray(", string("required"), ")", semicolon(kt))); + statement(indent(6), "schema.put(", string("properties"), ", props)", semicolon(kt))); + buffer.add( + statement(indent(6), "var req = new java.util.ArrayList()", semicolon(kt))); + buffer.add(statement(indent(6), "schema.put(", string("required"), ", req)", semicolon(kt))); } // --- PARAMETER SCHEMA GENERATION --- @@ -487,14 +492,8 @@ private List generateToolDefinition(boolean kt) { ")")); } - buffer.add( - statement( - indent(6), - "props.set(", - string(mcpName), - ", schema_", - mcpName, - ")")); + // Switched from .set() to .put() for standard Map + buffer.add(statement(indent(6), "props.put(", string(mcpName), ", schema_", mcpName, ")")); if (!param.isNullable(kt)) { buffer.add(statement(indent(6), "req.add(", string(mcpName), ")")); @@ -524,10 +523,11 @@ private List generateToolDefinition(boolean kt) { semicolon(kt))); } + // Switched from .set() to .put() for standard Map buffer.add( statement( indent(6), - "props.set(", + "props.put(", string(mcpName), ", schema_", mcpName, @@ -556,14 +556,15 @@ private List generateToolDefinition(boolean kt) { "Node = schemaGenerator.generateSchema(", returnTypeStr, "::class.java)")); + // Use this.json to convert the output schema buffer.add( statement( indent(6), "val ", outputSchemaArg, - " = mapper.convertValue(", + " = this.json.convertValue(", outputSchemaArg, - "Node, Map::class.java) as Map")); + "Node, java.util.Map::class.java) as java.util.Map")); } else { buffer.add( statement( @@ -574,12 +575,13 @@ private List generateToolDefinition(boolean kt) { returnTypeStr, ".class)", semicolon(kt))); + // Use this.json to convert the output schema buffer.add( statement( indent(6), "var ", outputSchemaArg, - " = mapper.convertValue(", + " = this.json.convertValue(", outputSchemaArg, "Node, java.util.Map.class)", semicolon(kt))); @@ -620,7 +622,8 @@ private List generateToolDefinition(boolean kt) { titleArg, ", ", string(description), - ", mapper.treeToValue(schema," + // Use this.json to convert the main schema map into JsonSchema + ", this.json.convertValue(schema," + " io.modelcontextprotocol.spec.McpSchema.JsonSchema::class.java), ", outputSchemaArg, ", ", @@ -657,7 +660,8 @@ private List generateToolDefinition(boolean kt) { titleArg, ", ", string(description), - ", mapper.treeToValue(schema," + // Use this.json to convert the main schema map into JsonSchema + ", this.json.convertValue(schema," + " io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), ", outputSchemaArg, ", ", diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java index b1e8a03a63..10412e0a83 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java @@ -365,23 +365,14 @@ public String toSourceCode(boolean kt) throws IOException { statement( indent(6), "this.json =" - + " app.services.require(io.modelcontextprotocol.json.McpJsonMapper::class.java)")); - buffer.append( - statement( - indent(6), - "val mapper = app.require(tools.jackson.databind.ObjectMapper::class.java)")); + + " app.require(io.modelcontextprotocol.json.McpJsonMapper::class.java)")); + if (!tools.isEmpty()) { - buffer.append( - statement( - indent(6), - "val configBuilder =" - + " com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder(com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12," - + " com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON)")); buffer.append( statement( indent(6), "val schemaGenerator =" - + " com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build())")); + + " app.require(com.github.victools.jsonschema.generator.SchemaGenerator::class.java)")); } } else { buffer.append(statement(indent(4), "@Override")); @@ -394,27 +385,15 @@ public String toSourceCode(boolean kt) throws IOException { buffer.append( statement( indent(6), - "this.json =" - + " app.getServices().require(io.modelcontextprotocol.json.McpJsonMapper.class)", - semicolon(kt))); - buffer.append( - statement( - indent(6), - "var mapper = app.require(tools.jackson.databind.ObjectMapper.class)", + "this.json =" + " app.require(io.modelcontextprotocol.json.McpJsonMapper.class)", semicolon(kt))); + if (!tools.isEmpty()) { buffer.append( statement( indent(6), - "var configBuilder = new" - + " com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder(com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12," - + " com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON)", - semicolon(kt))); - buffer.append( - statement( - indent(6), - "var schemaGenerator = new" - + " com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build())", + "var schemaGenerator =" + + " app.require(com.github.victools.jsonschema.generator.SchemaGenerator.class)", semicolon(kt))); } } @@ -438,7 +417,8 @@ public String toSourceCode(boolean kt) throws IOException { : "(exchange, req) -> this." + methodName + "(exchange, null, req)"); if (route.isMcpTool()) { - var defArgs = "mapper, schemaGenerator"; + // Removed "mapper" from defArgs + var defArgs = "schemaGenerator"; if (kt) { buffer.append( statement( diff --git a/modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java index 5b09e8feb7..42b089062a 100644 --- a/modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java +++ b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java @@ -18,7 +18,7 @@ public ExampleServerMcp_(ExampleServer instance) { } public ExampleServerMcp_(io.jooby.SneakyThrows.Supplier provider) { - setup(ctx -> (ExampleServer) provider.get()); + setup(ctx -> provider.get()); } public ExampleServerMcp_( @@ -89,18 +89,12 @@ public String serverKey() { public void install(io.jooby.Jooby app, io.modelcontextprotocol.server.McpSyncServer server) throws Exception { this.json = app.getServices().require(io.modelcontextprotocol.json.McpJsonMapper.class); - var mapper = app.require(tools.jackson.databind.ObjectMapper.class); - var configBuilder = - new com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder( - com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12, - com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON); var schemaGenerator = - new com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build()); + app.require(com.github.victools.jsonschema.generator.SchemaGenerator.class); server.addTool( new io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification( - addToolSpec(mapper, schemaGenerator), - (exchange, req) -> this.add(exchange, null, req))); + addToolSpec(schemaGenerator), (exchange, req) -> this.add(exchange, null, req))); server.addPrompt( new io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification( reviewCodePromptSpec(), (exchange, req) -> this.reviewCode(exchange, null, req))); @@ -118,17 +112,12 @@ public void install( io.jooby.Jooby app, io.modelcontextprotocol.server.McpStatelessSyncServer server) throws Exception { this.json = app.getServices().require(io.modelcontextprotocol.json.McpJsonMapper.class); - var mapper = app.require(tools.jackson.databind.ObjectMapper.class); - var configBuilder = - new com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder( - com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12, - com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON); var schemaGenerator = - new com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build()); + app.require(com.github.victools.jsonschema.generator.SchemaGenerator.class); server.addTool( new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncToolSpecification( - addToolSpec(mapper, schemaGenerator), (ctx, req) -> this.add(null, ctx, req))); + addToolSpec(schemaGenerator), (ctx, req) -> this.add(null, ctx, req))); server.addPrompt( new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncPromptSpecification( reviewCodePromptSpec(), (ctx, req) -> this.reviewCode(null, ctx, req))); @@ -143,19 +132,20 @@ public void install( } private io.modelcontextprotocol.spec.McpSchema.Tool addToolSpec( - tools.jackson.databind.ObjectMapper mapper, com.github.victools.jsonschema.generator.SchemaGenerator schemaGenerator) { - var schema = mapper.createObjectNode(); + var schema = new java.util.LinkedHashMap(); schema.put("type", "object"); - var props = schema.putObject("properties"); - var req = schema.putArray("required"); + var props = new java.util.LinkedHashMap(); + schema.put("properties", props); + var req = new java.util.ArrayList(); + schema.put("required", req); var schema_a = schemaGenerator.generateSchema(int.class); schema_a.put("description", "1st number"); - props.set("a", schema_a); + props.put("a", schema_a); req.add("a"); var schema_b = schemaGenerator.generateSchema(int.class); schema_b.put("description", "2nd number"); - props.set("b", schema_b); + props.put("b", schema_b); req.add("b"); var annotations = new io.modelcontextprotocol.spec.McpSchema.ToolAnnotations( @@ -164,7 +154,7 @@ private io.modelcontextprotocol.spec.McpSchema.Tool addToolSpec( "calculator", "Add two numbers.", "A simple calculator.", - mapper.treeToValue(schema, io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), + this.json.convertValue(schema, io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), null, annotations, null); diff --git a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java index c20d22dc1b..6c0cd4b8d4 100644 --- a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java +++ b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java @@ -79,12 +79,10 @@ public java.util.List this.add(exchange, null, req))); + server.addTool(new io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification(addToolSpec(schemaGenerator), (exchange, req) -> this.add(exchange, null, req))); server.addPrompt(new io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification(reviewCodePromptSpec(), (exchange, req) -> this.reviewCode(exchange, null, req))); server.addResource(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification(getLogsResourceSpec(), (exchange, req) -> this.getLogs(exchange, null, req))); server.addResourceTemplate(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceTemplateSpecification(getUserProfileResourceTemplateSpec(), (exchange, req) -> this.getUserProfile(exchange, null, req))); @@ -92,32 +90,32 @@ public void install(io.jooby.Jooby app, io.modelcontextprotocol.server.McpSyncSe @Override public void install(io.jooby.Jooby app, io.modelcontextprotocol.server.McpStatelessSyncServer server) throws Exception { - this.json = app.getServices().require(io.modelcontextprotocol.json.McpJsonMapper.class); - var mapper = app.require(tools.jackson.databind.ObjectMapper.class); - var configBuilder = new com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder(com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12, com.github.victools.jsonschema.generator.OptionPreset.PLAIN_JSON); - var schemaGenerator = new com.github.victools.jsonschema.generator.SchemaGenerator(configBuilder.build()); + this.json = app.require(io.modelcontextprotocol.json.McpJsonMapper.class); + var schemaGenerator = app.require(com.github.victools.jsonschema.generator.SchemaGenerator.class); - server.addTool(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncToolSpecification(addToolSpec(mapper, schemaGenerator), (ctx, req) -> this.add(null, ctx, req))); + server.addTool(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncToolSpecification(addToolSpec(schemaGenerator), (ctx, req) -> this.add(null, ctx, req))); server.addPrompt(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncPromptSpecification(reviewCodePromptSpec(), (ctx, req) -> this.reviewCode(null, ctx, req))); server.addResource(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceSpecification(getLogsResourceSpec(), (ctx, req) -> this.getLogs(null, ctx, req))); server.addResourceTemplate(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceTemplateSpecification(getUserProfileResourceTemplateSpec(), (ctx, req) -> this.getUserProfile(null, ctx, req))); } - private io.modelcontextprotocol.spec.McpSchema.Tool addToolSpec(tools.jackson.databind.ObjectMapper mapper, com.github.victools.jsonschema.generator.SchemaGenerator schemaGenerator) { - var schema = mapper.createObjectNode(); + private io.modelcontextprotocol.spec.McpSchema.Tool addToolSpec(com.github.victools.jsonschema.generator.SchemaGenerator schemaGenerator) { + var schema = new java.util.LinkedHashMap(); schema.put("type", "object"); - var props = schema.putObject("properties"); - var req = schema.putArray("required"); + var props = new java.util.LinkedHashMap(); + schema.put("properties", props); + var req = new java.util.ArrayList(); + schema.put("required", req); var schema_a = schemaGenerator.generateSchema(int.class); schema_a.put("description", "1st number"); - props.set("a", schema_a); + props.put("a", schema_a); req.add("a"); var schema_b = schemaGenerator.generateSchema(int.class); schema_b.put("description", "2nd number"); - props.set("b", schema_b); + props.put("b", schema_b); req.add("b"); var annotations = new io.modelcontextprotocol.spec.McpSchema.ToolAnnotations("Add two numbers.A simple calculator.", true, true, false, true, null); - return new io.modelcontextprotocol.spec.McpSchema.Tool("calculator", "Add two numbers.", "A simple calculator.", mapper.treeToValue(schema, io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), null, annotations, null); + return new io.modelcontextprotocol.spec.McpSchema.Tool("calculator", "Add two numbers.", "A simple calculator.", this.json.convertValue(schema, io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), null, annotations, null); } private io.modelcontextprotocol.spec.McpSchema.CallToolResult add(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.CallToolRequest req) { diff --git a/modules/jooby-mcp-jackson2/pom.xml b/modules/jooby-mcp-jackson2/pom.xml new file mode 100644 index 0000000000..25c3f21105 --- /dev/null +++ b/modules/jooby-mcp-jackson2/pom.xml @@ -0,0 +1,31 @@ + + + 4.0.0 + + io.jooby + modules + 4.1.1-SNAPSHOT + + + jooby-mcp-jackson2 + jooby-mcp-jackson2 + + + + io.jooby + jooby + ${jooby.version} + + + io.modelcontextprotocol.sdk + mcp-json-jackson2 + + + com.github.victools + jsonschema-generator + 4.38.0 + + + diff --git a/modules/jooby-mcp-jackson2/src/main/java/io/jooby/mcp/jackson2/McpJackson2Module.java b/modules/jooby-mcp-jackson2/src/main/java/io/jooby/mcp/jackson2/McpJackson2Module.java new file mode 100644 index 0000000000..bf64fd8e0e --- /dev/null +++ b/modules/jooby-mcp-jackson2/src/main/java/io/jooby/mcp/jackson2/McpJackson2Module.java @@ -0,0 +1,33 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.mcp.jackson2; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.victools.jsonschema.generator.OptionPreset; +import com.github.victools.jsonschema.generator.SchemaGenerator; +import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder; +import com.github.victools.jsonschema.generator.SchemaVersion; +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.Extension; +import io.jooby.Jooby; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; + +public class McpJackson2Module implements Extension { + @Override + public void install(@NonNull Jooby application) throws Exception { + var services = application.getServices(); + var jsonMapper = services.require(ObjectMapper.class); + var mcpJsonMapper = new JacksonMcpJsonMapper(jsonMapper); + services.put(McpJsonMapper.class, mcpJsonMapper); + + // schema generator + var configBuilder = + new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON); + var schemaGenerator = new SchemaGenerator(configBuilder.build()); + services.put(SchemaGenerator.class, schemaGenerator); + } +} diff --git a/modules/jooby-mcp-jackson3/pom.xml b/modules/jooby-mcp-jackson3/pom.xml new file mode 100644 index 0000000000..ad09bb1782 --- /dev/null +++ b/modules/jooby-mcp-jackson3/pom.xml @@ -0,0 +1,31 @@ + + + 4.0.0 + + io.jooby + modules + 4.1.1-SNAPSHOT + + + jooby-mcp-jackson3 + jooby-mcp-jackson3 + + + + io.jooby + jooby + ${jooby.version} + + + io.modelcontextprotocol.sdk + mcp-json-jackson3 + + + com.github.victools + jsonschema-generator + 5.0.0 + + + diff --git a/modules/jooby-mcp-jackson3/src/main/java/io/jooby/mcp/jackson3/McpJackson3Module.java b/modules/jooby-mcp-jackson3/src/main/java/io/jooby/mcp/jackson3/McpJackson3Module.java new file mode 100644 index 0000000000..ef9e34b173 --- /dev/null +++ b/modules/jooby-mcp-jackson3/src/main/java/io/jooby/mcp/jackson3/McpJackson3Module.java @@ -0,0 +1,33 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.mcp.jackson3; + +import com.github.victools.jsonschema.generator.OptionPreset; +import com.github.victools.jsonschema.generator.SchemaGenerator; +import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder; +import com.github.victools.jsonschema.generator.SchemaVersion; +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.Extension; +import io.jooby.Jooby; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapper; +import tools.jackson.databind.json.JsonMapper; + +public class McpJackson3Module implements Extension { + @Override + public void install(@NonNull Jooby application) throws Exception { + var services = application.getServices(); + var jsonMapper = services.require(JsonMapper.class); + var mcpJsonMapper = new JacksonMcpJsonMapper(jsonMapper); + services.put(McpJsonMapper.class, mcpJsonMapper); + + // schema generator + var configBuilder = + new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON); + var schemaGenerator = new SchemaGenerator(configBuilder.build()); + services.put(SchemaGenerator.class, schemaGenerator); + } +} diff --git a/modules/jooby-mcp/pom.xml b/modules/jooby-mcp/pom.xml index b498459f2b..e169662125 100644 --- a/modules/jooby-mcp/pom.xml +++ b/modules/jooby-mcp/pom.xml @@ -18,18 +18,9 @@ jooby ${jooby.version} - - com.fasterxml.jackson.core - jackson-databind - io.modelcontextprotocol.sdk - mcp - - - com.github.victools - jsonschema-generator - 5.0.0 + mcp-core diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java index 145d3be514..134183e9cc 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java @@ -23,10 +23,8 @@ import io.jooby.mcp.transport.WebSocketTransportProvider; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonMapper; -import io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapper; import io.modelcontextprotocol.server.*; import io.modelcontextprotocol.spec.McpSchema; -import tools.jackson.databind.json.JsonMapper; /** * MCP (Model Context Protocol) module for Jooby. @@ -123,7 +121,6 @@ public class McpModule implements Extension { private Transport defaultTransport = STREAMABLE_HTTP; - private McpJsonMapper mcpJsonMapper; private final List mcpServices = new ArrayList<>(); public McpModule(McpService mcpService, McpService... mcpServices) { @@ -141,15 +138,14 @@ public McpModule transport(@NonNull Transport transport) { @Override public void install(@NonNull Jooby app) { var services = app.getServices(); - if (mcpJsonMapper == null) { - this.mcpJsonMapper = new JacksonMcpJsonMapper(services.require(JsonMapper.class)); - } - services.put(McpJsonMapper.class, mcpJsonMapper); + var mcpJsonMapper = services.require(McpJsonMapper.class); + // Group services by server var mcpServiceMap = new HashMap>(); for (var mcpService : mcpServices) { var serverKey = Optional.ofNullable(mcpService.serverKey()).orElse("default"); mcpServiceMap.computeIfAbsent(serverKey, k -> new ArrayList<>()).add(mcpService); } + // Boot everything for (var serverEntry : mcpServiceMap.entrySet()) { var mcpConfig = mcpServerConfig(app, serverEntry.getKey()); var capabilities = new McpSchema.ServerCapabilities.Builder(); @@ -234,11 +230,6 @@ private McpServerConfig mcpServerConfig(Jooby application, String key) { } } - public McpModule mcpJsonMapper(McpJsonMapper mcpJsonMapper) { - this.mcpJsonMapper = mcpJsonMapper; - return this; - } - public enum Transport { SSE("sse"), STREAMABLE_HTTP("streamable-http"), diff --git a/modules/pom.xml b/modules/pom.xml index e1a16eb4a3..51c3e41ea9 100644 --- a/modules/pom.xml +++ b/modules/pom.xml @@ -35,8 +35,12 @@ jooby-langchain4j - + + jooby-mcp + jooby-mcp-jackson2 + jooby-mcp-jackson3 jooby-trpc + jooby-grpc jooby-hikari @@ -69,8 +73,6 @@ jooby-thymeleaf jooby-camel - jooby-grpc - jooby-avaje-validator jooby-hibernate-validator @@ -108,7 +110,6 @@ jooby-stork jooby-gradle-setup - jooby-mcp diff --git a/pom.xml b/pom.xml index 17daa20cd7..84bdee1467 100644 --- a/pom.xml +++ b/pom.xml @@ -578,6 +578,42 @@ ${jooby.version} + + io.jooby + jooby-javadoc + ${jooby.version} + + + + io.jooby + jooby-grpc + ${jooby.version} + + + + io.jooby + jooby-trpc + ${jooby.version} + + + + io.jooby + jooby-mcp + ${jooby.version} + + + + io.jooby + jooby-mcp-jackson2 + ${jooby.version} + + + + io.jooby + jooby-mcp-jackson3 + ${jooby.version} + + com.github.ben-manes.caffeine caffeine diff --git a/tests/pom.xml b/tests/pom.xml index 71f573db99..42a4bfc02d 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -151,6 +151,16 @@ jooby-mcp ${jooby.version} + + io.jooby + jooby-mcp-jackson2 + ${jooby.version} + + + io.jooby + jooby-mcp-jackson3 + ${jooby.version} + io.jooby jooby-test diff --git a/tests/src/test/java/io/jooby/i3830/CalculatorToolsTest.java b/tests/src/test/java/io/jooby/i3830/CalculatorToolsTest.java index 4bdcd986c6..358c321ce2 100644 --- a/tests/src/test/java/io/jooby/i3830/CalculatorToolsTest.java +++ b/tests/src/test/java/io/jooby/i3830/CalculatorToolsTest.java @@ -23,27 +23,20 @@ import io.jooby.junit.ServerTest; import io.jooby.junit.ServerTestRunner; import io.jooby.mcp.McpModule; +import io.jooby.mcp.jackson3.McpJackson3Module; public class CalculatorToolsTest { - private void setupMcpApp(Jooby app) { + private void setupMcpApp(Jooby app, McpModule.Transport transport) { app.install(new Jackson3Module()); - app.install( - new McpModule(new CalculatorToolsMcp_()) - .transport(McpModule.Transport.STATELESS_STREAMABLE_HTTP)); + app.install(new McpJackson3Module()); + app.install(new McpModule(new CalculatorToolsMcp_()).transport(transport)); } @ServerTest public void shouldCallToolOverStreamableHttp(ServerTestRunner runner) { runner - .define( - app -> { - app.install(new Jackson3Module()); - // Register the module using the STREAMABLE_HTTP transport - app.install( - new McpModule(new CalculatorToolsMcp_()) - .transport(McpModule.Transport.STREAMABLE_HTTP)); - }) + .define(app -> setupMcpApp(app, McpModule.Transport.STREAMABLE_HTTP)) .ready( client -> { AtomicReference sessionId = new AtomicReference<>(); @@ -120,12 +113,7 @@ public void shouldCallToolOverStreamableHttp(ServerTestRunner runner) { @ServerTest public void shouldCallToolOverSse(ServerTestRunner runner) { runner - .define( - app -> { - app.install(new Jackson3Module()); - app.install( - new McpModule(new CalculatorToolsMcp_()).transport(McpModule.Transport.SSE)); - }) + .define(app -> setupMcpApp(app, McpModule.Transport.SSE)) .ready( client -> { String initRequest = @@ -233,7 +221,7 @@ public void shouldCallToolOverSse(ServerTestRunner runner) { @ServerTest public void shouldCallAddNumbersTool(ServerTestRunner runner) { runner - .define(this::setupMcpApp) + .define(app -> setupMcpApp(app, McpModule.Transport.STATELESS_STREAMABLE_HTTP)) .ready( client -> { String jsonRpcRequest = @@ -267,14 +255,7 @@ public void shouldCallAddNumbersTool(ServerTestRunner runner) { @ServerTest public void shouldCallToolOverWebSocket(ServerTestRunner runner) throws Exception { runner - .define( - app -> { - app.install(new Jackson3Module()); - // Register the module using our brand new WEBSOCKET transport! - app.install( - new McpModule(new CalculatorToolsMcp_()) - .transport(McpModule.Transport.WEBSOCKET)); - }) + .define(app -> setupMcpApp(app, McpModule.Transport.WEBSOCKET)) .ready( client -> { CountDownLatch initLatch = new CountDownLatch(1); @@ -382,7 +363,7 @@ public CompletionStage onText( @ServerTest public void shouldGetMathTutorPrompt(ServerTestRunner runner) { runner - .define(this::setupMcpApp) + .define(app -> setupMcpApp(app, McpModule.Transport.STATELESS_STREAMABLE_HTTP)) .ready( client -> { String jsonRpcRequest = @@ -418,7 +399,7 @@ public void shouldGetMathTutorPrompt(ServerTestRunner runner) { @ServerTest public void shouldReadStaticResource(ServerTestRunner runner) { runner - .define(this::setupMcpApp) + .define(app -> setupMcpApp(app, McpModule.Transport.STATELESS_STREAMABLE_HTTP)) .ready( client -> { String jsonRpcRequest = @@ -453,7 +434,7 @@ public void shouldReadStaticResource(ServerTestRunner runner) { @ServerTest public void shouldReadResourceTemplate(ServerTestRunner runner) { runner - .define(this::setupMcpApp) + .define(app -> setupMcpApp(app, McpModule.Transport.STATELESS_STREAMABLE_HTTP)) .ready( client -> { String jsonRpcRequest = @@ -488,7 +469,7 @@ public void shouldReadResourceTemplate(ServerTestRunner runner) { @ServerTest public void shouldGetHistoryCompletion(ServerTestRunner runner) { runner - .define(this::setupMcpApp) + .define(app -> setupMcpApp(app, McpModule.Transport.STATELESS_STREAMABLE_HTTP)) .ready( client -> { String jsonRpcRequest = diff --git a/tests/src/test/java/io/jooby/i3830/McpExchangeInjectionTest.java b/tests/src/test/java/io/jooby/i3830/McpExchangeInjectionTest.java index 0e7cb710d5..55422c64eb 100644 --- a/tests/src/test/java/io/jooby/i3830/McpExchangeInjectionTest.java +++ b/tests/src/test/java/io/jooby/i3830/McpExchangeInjectionTest.java @@ -15,6 +15,7 @@ import io.jooby.junit.ServerTest; import io.jooby.junit.ServerTestRunner; import io.jooby.mcp.McpModule; +import io.jooby.mcp.jackson3.McpJackson3Module; public class McpExchangeInjectionTest { @@ -24,6 +25,7 @@ public void shouldInjectExchangeAndAccessSession(ServerTestRunner runner) throws .define( app -> { app.install(new Jackson3Module()); + app.install(new McpJackson3Module()); // Register the module using the STREAMABLE_HTTP transport app.install( new McpModule(new CalculatorToolsMcp_()) diff --git a/tests/src/test/java/io/jooby/i3830/UserToolsTest.java b/tests/src/test/java/io/jooby/i3830/UserToolsTest.java index 9e2eae65c7..2edeaa799c 100644 --- a/tests/src/test/java/io/jooby/i3830/UserToolsTest.java +++ b/tests/src/test/java/io/jooby/i3830/UserToolsTest.java @@ -7,66 +7,87 @@ import static org.assertj.core.api.Assertions.assertThat; +import org.assertj.core.api.Assertions; + +import io.jooby.Extension; +import io.jooby.Jooby; +import io.jooby.SneakyThrows; +import io.jooby.jackson.JacksonModule; import io.jooby.jackson3.Jackson3Module; import io.jooby.junit.ServerTest; import io.jooby.junit.ServerTestRunner; import io.jooby.mcp.McpModule; +import io.jooby.mcp.jackson2.McpJackson2Module; +import io.jooby.mcp.jackson3.McpJackson3Module; +import io.jooby.test.WebClient; public class UserToolsTest { + private void setupMcpApp(Jooby app, Extension... extensions) { + for (var extension : extensions) { + app.install(extension); + } + app.install( + new McpModule(new UserToolsMcp_()) + .transport(McpModule.Transport.STATELESS_STREAMABLE_HTTP)); + } + @ServerTest - public void shouldReturnStructuredJsonObject(ServerTestRunner runner) { + public void shouldReturnStructuredJsonObjectOnJackson2(ServerTestRunner runner) { runner - .define( - app -> { - app.install(new Jackson3Module()); - // Register the tool using the stateless transport - app.install( - new McpModule(new UserToolsMcp_()) - .transport(McpModule.Transport.STATELESS_STREAMABLE_HTTP)); - }) - .ready( - client -> { - // 1. Ask for the structured user profile - String jsonRpcRequest = - """ - { - "jsonrpc": "2.0", - "id": "req-user-1", - "method": "tools/call", - "params": { - "name": "get_user_profile", - "arguments": { - "username": "edgar" - } - } - } - """; + .define(app -> setupMcpApp(app, new JacksonModule(), new McpJackson2Module())) + .ready(assertStructuredJson()); + } + + @ServerTest + public void shouldReturnStructuredJsonObjectOnJackson3(ServerTestRunner runner) { + runner + .define(app -> setupMcpApp(app, new Jackson3Module(), new McpJackson3Module())) + .ready(assertStructuredJson()); + } + + private static SneakyThrows.Consumer assertStructuredJson() { + return client -> { + // 1. Ask for the structured user profile + String jsonRpcRequest = + """ + { + "jsonrpc": "2.0", + "id": "req-user-1", + "method": "tools/call", + "params": { + "name": "get_user_profile", + "arguments": { + "username": "edgar" + } + } + } + """; - client.header("Accept", "text/event-stream, application/json"); - client.header("Content-Type", "application/json"); + client.header("Accept", "text/event-stream, application/json"); + client.header("Content-Type", "application/json"); - client.postJson( - "/mcp", - jsonRpcRequest, - response -> { - assertThat(response.code()).isEqualTo(200); + client.postJson( + "/mcp", + jsonRpcRequest, + response -> { + Assertions.assertThat(response.code()).isEqualTo(200); - String body = response.body().string(); + String body = response.body().string(); - // 2. Verify the response ID matches - assertThat(body).containsPattern("\"id\":\\s*\"req-user-1\""); + // 2. Verify the response ID matches + assertThat(body).containsPattern("\"id\":\\s*\"req-user-1\""); - // 3. Verify the Java Record was correctly serialized into the tool's text - // content! - assertThat(body) - .contains("\"username\":\"edgar\"") - .contains("\"role\":\"admin\"") - .contains("\"active\":true") - .as( - "The output should be a fully structured JSON payload representing the" - + " Record"); - }); - }); + // 3. Verify the Java Record was correctly serialized into the tool's text + // content! + assertThat(body) + .contains("\"username\":\"edgar\"") + .contains("\"role\":\"admin\"") + .contains("\"active\":true") + .as( + "The output should be a fully structured JSON payload representing the" + + " Record"); + }); + }; } } From d33b90bc7dd8b6fb5f4fc9bbb02c95ad5b478c72 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sun, 29 Mar 2026 12:03:22 -0300 Subject: [PATCH 28/37] - bug fixing session/code refactor - move transport as internal package - add mcp-inspector - add automatic-module-name - fix error for missing completions --- .../java/io/jooby/internal/apt/McpRoute.java | 129 +++++-- .../java/io/jooby/internal/apt/McpRouter.java | 77 ++++- .../java/tests/i3830/ExampleServerMcp_.java | 326 ------------------ .../src/test/java/tests/i3830/Issue3830.java | 9 +- modules/jooby-bom/pom.xml | 20 ++ modules/jooby-mcp-jackson2/pom.xml | 16 + modules/jooby-mcp-jackson3/pom.xml | 16 + modules/jooby-mcp/pom.xml | 17 + .../mcp/transport/AbstractMcpTransport.java | 2 +- .../AbstractMcpTransportProvider.java | 2 +- .../mcp/transport/SendError.java | 2 +- .../mcp/transport/SseTransportProvider.java | 8 +- .../transport/StatelessTransportProvider.java | 4 +- .../StreamableTransportProvider.java | 7 +- .../mcp/transport/TransportConstants.java | 2 +- .../transport/WebSocketTransportProvider.java | 6 +- .../java/io/jooby/mcp/McpInspectorModule.java | 204 +++++++++++ .../src/main/java/io/jooby/mcp/McpModule.java | 33 +- .../assets/autoConnectScript-B8iPFz0O.js | 14 + .../assets/initScript-B8iPFz0O.js | 75 ++++ pom.xml | 2 +- 21 files changed, 580 insertions(+), 391 deletions(-) delete mode 100644 modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java rename modules/jooby-mcp/src/main/java/io/jooby/{ => internal}/mcp/transport/AbstractMcpTransport.java (95%) rename modules/jooby-mcp/src/main/java/io/jooby/{ => internal}/mcp/transport/AbstractMcpTransportProvider.java (98%) rename modules/jooby-mcp/src/main/java/io/jooby/{ => internal}/mcp/transport/SendError.java (99%) rename modules/jooby-mcp/src/main/java/io/jooby/{ => internal}/mcp/transport/SseTransportProvider.java (95%) rename modules/jooby-mcp/src/main/java/io/jooby/{ => internal}/mcp/transport/StatelessTransportProvider.java (97%) rename modules/jooby-mcp/src/main/java/io/jooby/{ => internal}/mcp/transport/StreamableTransportProvider.java (98%) rename modules/jooby-mcp/src/main/java/io/jooby/{ => internal}/mcp/transport/TransportConstants.java (91%) rename modules/jooby-mcp/src/main/java/io/jooby/{ => internal}/mcp/transport/WebSocketTransportProvider.java (95%) create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInspectorModule.java create mode 100644 modules/jooby-mcp/src/main/resources/mcpInspector/assets/autoConnectScript-B8iPFz0O.js create mode 100644 modules/jooby-mcp/src/main/resources/mcpInspector/assets/initScript-B8iPFz0O.js diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java index 30a49ccdb8..957f6bd884 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java @@ -838,7 +838,8 @@ public List generateMcpHandlerMethod(boolean kt) { if (kt) { buffer.add( statement(indent(6), "val raw_", javaName, " = args.get(", string(mcpName), ")")); - if (!isNullable) + + if (!isNullable) { buffer.add( statement( indent(6), @@ -847,16 +848,73 @@ public List generateMcpHandlerMethod(boolean kt) { " == null) throw IllegalArgumentException(", string("Missing req param: " + mcpName), ")")); - buffer.add( - statement( - indent(6), - "val ", - javaName, - " = raw_", - javaName, - " as ", - type, - isNullable ? "?" : "")); + } + + boolean isNumber = isNumber(type); + + if (isNumber) { + String ktType = "Int"; + if (type.contains("Double") || type.equals("double")) ktType = "Double"; + else if (type.contains("Long") || type.equals("long")) ktType = "Long"; + else if (type.contains("Float") || type.equals("float")) ktType = "Float"; + else if (type.contains("Short") || type.equals("short")) ktType = "Short"; + else if (type.contains("Byte") || type.equals("byte")) ktType = "Byte"; + + if (isNullable) { + buffer.add( + statement( + indent(6), + "val ", + javaName, + " = (raw_", + javaName, + " as? Number)?.to", + ktType, + "()")); + } else { + buffer.add( + statement( + indent(6), + "val ", + javaName, + " = (raw_", + javaName, + " as Number).to", + ktType, + "()")); + } + } else if (type.equals("java.lang.String") || type.equals("String")) { + buffer.add( + statement( + indent(6), + "val ", + javaName, + " = raw_", + javaName, + isNullable ? "?.toString()" : ".toString()")); + } else if (type.equals("boolean") || type.equals("java.lang.Boolean")) { + buffer.add( + statement( + indent(6), + "val ", + javaName, + " = raw_", + javaName, + " as Boolean", + isNullable ? "?" : "")); + } else { + buffer.add( + statement( + indent(6), + "val ", + javaName, + " = raw_", + javaName, + " as ", + type, + isNullable ? "?" : "")); + } + } else { buffer.add( statement( @@ -867,7 +925,8 @@ public List generateMcpHandlerMethod(boolean kt) { string(mcpName), ")", semicolon(kt))); - if (!isNullable) + + if (!isNullable) { buffer.add( statement( indent(6), @@ -877,8 +936,21 @@ public List generateMcpHandlerMethod(boolean kt) { string("Missing req param: " + mcpName), ")", semicolon(kt))); + } + + boolean isNumber = isNumber(type); + + if (isNumber) { + String primitiveName = + switch (type) { + case "double", "java.lang.Double" -> "double"; + case "long", "java.lang.Long" -> "long"; + case "float", "java.lang.Float" -> "float"; + case "short", "java.lang.Short" -> "short"; + case "byte", "java.lang.Byte" -> "byte"; + default -> "int"; + }; - if (type.equals("int") || type.equals("java.lang.Integer")) { buffer.add( statement( indent(6), @@ -886,15 +958,14 @@ public List generateMcpHandlerMethod(boolean kt) { javaName, " = ", isNullable ? "(raw_" + javaName + " == null) ? null : " : "", - "raw_", - javaName, - " instanceof Number ? ((Number) raw_", + "((Number) raw_", javaName, - ").intValue() : Integer.parseInt(raw_", - javaName, - ".toString())", + ").", + primitiveName, + "Value()", semicolon(kt))); - } else if (type.equals("java.lang.String")) { + + } else if (type.equals("java.lang.String") || type.equals("String")) { buffer.add( statement( indent(6), @@ -906,6 +977,9 @@ public List generateMcpHandlerMethod(boolean kt) { javaName, ".toString() : null", semicolon(kt))); + } else if (type.equals("boolean") || type.equals("java.lang.Boolean")) { + buffer.add( + statement(indent(6), "var ", javaName, " = (Boolean) raw_", javaName, semicolon(kt))); } else { buffer.add( statement( @@ -981,6 +1055,21 @@ public List generateMcpHandlerMethod(boolean kt) { return buffer; } + private static boolean isNumber(String type) { + return type.equals("int") + || type.equals("java.lang.Integer") + || type.equals("double") + || type.equals("java.lang.Double") + || type.equals("long") + || type.equals("java.lang.Long") + || type.equals("float") + || type.equals("java.lang.Float") + || type.equals("short") + || type.equals("java.lang.Short") + || type.equals("byte") + || type.equals("java.lang.Byte"); + } + private boolean hasOutputSchema() { var returnTypeStr = getReturnType().getRawType().toString(); var isPrimitive = diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java index 10412e0a83..5f8886283e 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java @@ -152,6 +152,37 @@ public String toSourceCode(boolean kt) throws IOException { completionGroups.computeIfAbsent(ref, k -> new ArrayList<>()).add(route); } + // Gather all valid references so we can register dummy completions for targets that lack them + var allCompletionRefs = new java.util.LinkedHashSet(); + for (var route : prompts) { + var annotation = + AnnotationSupport.findAnnotationByName( + route.getMethod(), "io.jooby.annotation.mcp.McpPrompt"); + var name = + annotation != null + ? AnnotationSupport.findAnnotationValue(annotation, "name"::equals).stream() + .findFirst() + .orElse("") + : ""; + if (name.isEmpty()) name = route.getMethodName(); + allCompletionRefs.add(name); + } + for (var route : resources) { + if (route.isMcpResourceTemplate()) { + var annotation = + AnnotationSupport.findAnnotationByName( + route.getMethod(), "io.jooby.annotation.mcp.McpResource"); + var uri = + annotation != null + ? AnnotationSupport.findAnnotationValue(annotation, "uri"::equals).stream() + .findFirst() + .orElse("") + : ""; + allCompletionRefs.add(uri); + } + } + allCompletionRefs.addAll(completionGroups.keySet()); + if (kt) { buffer.append( statement( @@ -233,18 +264,29 @@ public String toSourceCode(boolean kt) throws IOException { semicolon(kt))); } - for (var ref : completionGroups.keySet()) { + // Loop over ALL possible refs, not just the ones with explicit handlers + for (var ref : allCompletionRefs) { var isResource = ref.contains("://"); - var handlerName = findTargetMethodName(ref) + "CompletionHandler"; var refObj = isResource ? "io.modelcontextprotocol.spec.McpSchema.ResourceReference" : "io.modelcontextprotocol.spec.McpSchema.PromptReference"; - String lambda = - kt - ? "{ exchange, req -> this." + handlerName + "(exchange, null, req) }" - : "(exchange, req) -> this." + handlerName + "(exchange, null, req)"; + String lambda; + if (completionGroups.containsKey(ref)) { + var handlerName = findTargetMethodName(ref) + "CompletionHandler"; + lambda = + kt + ? "{ exchange, req -> this." + handlerName + "(exchange, null, req) }" + : "(exchange, req) -> this." + handlerName + "(exchange, null, req)"; + } else { + // Fallback: Return an empty completion result safely + lambda = + kt + ? "{ _, _ -> io.jooby.mcp.McpResult(this.json).toCompleteResult(emptyList()) }" + : "(exchange, req) -> new" + + " io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of())"; + } if (kt) { buffer.append( @@ -305,18 +347,29 @@ public String toSourceCode(boolean kt) throws IOException { semicolon(kt))); } - for (var ref : completionGroups.keySet()) { + // Loop over ALL possible refs + for (var ref : allCompletionRefs) { var isResource = ref.contains("://"); - var handlerName = findTargetMethodName(ref) + "CompletionHandler"; var refObj = isResource ? "io.modelcontextprotocol.spec.McpSchema.ResourceReference" : "io.modelcontextprotocol.spec.McpSchema.PromptReference"; - String lambda = - kt - ? "{ ctx, req -> this." + handlerName + "(null, ctx, req) }" - : "(ctx, req) -> this." + handlerName + "(null, ctx, req)"; + String lambda; + if (completionGroups.containsKey(ref)) { + var handlerName = findTargetMethodName(ref) + "CompletionHandler"; + lambda = + kt + ? "{ ctx, req -> this." + handlerName + "(null, ctx, req) }" + : "(ctx, req) -> this." + handlerName + "(null, ctx, req)"; + } else { + // Fallback: Return an empty completion result safely + lambda = + kt + ? "{ _, _ -> io.jooby.mcp.McpResult(this.json).toCompleteResult(emptyList()) }" + : "(ctx, req) -> new" + + " io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of())"; + } if (kt) { buffer.append( diff --git a/modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java deleted file mode 100644 index 42b089062a..0000000000 --- a/modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java +++ /dev/null @@ -1,326 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package tests.i3830; - -@io.jooby.annotation.Generated(ExampleServer.class) -public class ExampleServerMcp_ implements io.jooby.mcp.McpService { - protected java.util.function.Function factory; - - public ExampleServerMcp_() { - this(io.jooby.SneakyThrows.singleton(ExampleServer::new)); - } - - public ExampleServerMcp_(ExampleServer instance) { - setup(ctx -> instance); - } - - public ExampleServerMcp_(io.jooby.SneakyThrows.Supplier provider) { - setup(ctx -> provider.get()); - } - - public ExampleServerMcp_( - io.jooby.SneakyThrows.Function, ExampleServer> provider) { - setup(ctx -> provider.apply(ExampleServer.class)); - } - - private void setup(java.util.function.Function factory) { - this.factory = factory; - } - - private io.modelcontextprotocol.json.McpJsonMapper json; - - @Override - public void capabilities( - io.modelcontextprotocol.spec.McpSchema.ServerCapabilities.Builder capabilities) { - capabilities.tools(true); - capabilities.prompts(true); - capabilities.resources(true, true); - capabilities.completions(); - } - - @Override - public String serverKey() { - return "example-server"; - } - - @Override - public java.util.List< - io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification> - completions() { - var completions = - new java.util.ArrayList< - io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification>(); - completions.add( - new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification( - new io.modelcontextprotocol.spec.McpSchema.ResourceReference( - "file:///users/{id}/{name}/profile"), - (exchange, req) -> this.getUserProfileCompletionHandler(exchange, null, req))); - completions.add( - new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification( - new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), - (exchange, req) -> this.reviewCodeCompletionHandler(exchange, null, req))); - return completions; - } - - @Override - public java.util.List< - io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification> - statelessCompletions() { - var completions = - new java.util.ArrayList< - io.modelcontextprotocol.server.McpStatelessServerFeatures - .SyncCompletionSpecification>(); - completions.add( - new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification( - new io.modelcontextprotocol.spec.McpSchema.ResourceReference( - "file:///users/{id}/{name}/profile"), - (ctx, req) -> this.getUserProfileCompletionHandler(null, ctx, req))); - completions.add( - new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification( - new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), - (ctx, req) -> this.reviewCodeCompletionHandler(null, ctx, req))); - return completions; - } - - @Override - public void install(io.jooby.Jooby app, io.modelcontextprotocol.server.McpSyncServer server) - throws Exception { - this.json = app.getServices().require(io.modelcontextprotocol.json.McpJsonMapper.class); - var schemaGenerator = - app.require(com.github.victools.jsonschema.generator.SchemaGenerator.class); - - server.addTool( - new io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification( - addToolSpec(schemaGenerator), (exchange, req) -> this.add(exchange, null, req))); - server.addPrompt( - new io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification( - reviewCodePromptSpec(), (exchange, req) -> this.reviewCode(exchange, null, req))); - server.addResource( - new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification( - getLogsResourceSpec(), (exchange, req) -> this.getLogs(exchange, null, req))); - server.addResourceTemplate( - new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceTemplateSpecification( - getUserProfileResourceTemplateSpec(), - (exchange, req) -> this.getUserProfile(exchange, null, req))); - } - - @Override - public void install( - io.jooby.Jooby app, io.modelcontextprotocol.server.McpStatelessSyncServer server) - throws Exception { - this.json = app.getServices().require(io.modelcontextprotocol.json.McpJsonMapper.class); - var schemaGenerator = - app.require(com.github.victools.jsonschema.generator.SchemaGenerator.class); - - server.addTool( - new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncToolSpecification( - addToolSpec(schemaGenerator), (ctx, req) -> this.add(null, ctx, req))); - server.addPrompt( - new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncPromptSpecification( - reviewCodePromptSpec(), (ctx, req) -> this.reviewCode(null, ctx, req))); - server.addResource( - new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceSpecification( - getLogsResourceSpec(), (ctx, req) -> this.getLogs(null, ctx, req))); - server.addResourceTemplate( - new io.modelcontextprotocol.server.McpStatelessServerFeatures - .SyncResourceTemplateSpecification( - getUserProfileResourceTemplateSpec(), - (ctx, req) -> this.getUserProfile(null, ctx, req))); - } - - private io.modelcontextprotocol.spec.McpSchema.Tool addToolSpec( - com.github.victools.jsonschema.generator.SchemaGenerator schemaGenerator) { - var schema = new java.util.LinkedHashMap(); - schema.put("type", "object"); - var props = new java.util.LinkedHashMap(); - schema.put("properties", props); - var req = new java.util.ArrayList(); - schema.put("required", req); - var schema_a = schemaGenerator.generateSchema(int.class); - schema_a.put("description", "1st number"); - props.put("a", schema_a); - req.add("a"); - var schema_b = schemaGenerator.generateSchema(int.class); - schema_b.put("description", "2nd number"); - props.put("b", schema_b); - req.add("b"); - var annotations = - new io.modelcontextprotocol.spec.McpSchema.ToolAnnotations( - "Add two numbers.A simple calculator.", true, true, false, true, null); - return new io.modelcontextprotocol.spec.McpSchema.Tool( - "calculator", - "Add two numbers.", - "A simple calculator.", - this.json.convertValue(schema, io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), - null, - annotations, - null); - } - - private io.modelcontextprotocol.spec.McpSchema.CallToolResult add( - io.modelcontextprotocol.server.McpSyncServerExchange exchange, - io.modelcontextprotocol.common.McpTransportContext transportContext, - io.modelcontextprotocol.spec.McpSchema.CallToolRequest req) { - var ctx = - exchange != null - ? (io.jooby.Context) exchange.transportContext().get("CTX") - : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); - var args = - req.arguments() != null - ? req.arguments() - : java.util.Collections.emptyMap(); - var c = this.factory.apply(ctx); - var raw_a = args.get("a"); - if (raw_a == null) throw new IllegalArgumentException("Missing req param: a"); - var a = - raw_a instanceof Number ? ((Number) raw_a).intValue() : Integer.parseInt(raw_a.toString()); - var raw_b = args.get("b"); - if (raw_b == null) throw new IllegalArgumentException("Missing req param: b"); - var b = - raw_b instanceof Number ? ((Number) raw_b).intValue() : Integer.parseInt(raw_b.toString()); - var result = c.add(a, b); - return new io.jooby.mcp.McpResult(this.json).toCallToolResult(result, false); - } - - private io.modelcontextprotocol.spec.McpSchema.Prompt reviewCodePromptSpec() { - var args = new java.util.ArrayList(); - args.add(new io.modelcontextprotocol.spec.McpSchema.PromptArgument("language", null, false)); - args.add(new io.modelcontextprotocol.spec.McpSchema.PromptArgument("code", null, false)); - return new io.modelcontextprotocol.spec.McpSchema.Prompt( - "review_code", - "Review code.", - "Reviews the given code snippet in the context of the specified programming language.", - args); - } - - private io.modelcontextprotocol.spec.McpSchema.GetPromptResult reviewCode( - io.modelcontextprotocol.server.McpSyncServerExchange exchange, - io.modelcontextprotocol.common.McpTransportContext transportContext, - io.modelcontextprotocol.spec.McpSchema.GetPromptRequest req) { - var ctx = - exchange != null - ? (io.jooby.Context) exchange.transportContext().get("CTX") - : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); - var args = - req.arguments() != null - ? req.arguments() - : java.util.Collections.emptyMap(); - var c = this.factory.apply(ctx); - var raw_language = args.get("language"); - var language = raw_language != null ? raw_language.toString() : null; - var raw_code = args.get("code"); - var code = raw_code != null ? raw_code.toString() : null; - var result = c.reviewCode(language, code); - return new io.jooby.mcp.McpResult(this.json).toPromptResult(result); - } - - private io.modelcontextprotocol.spec.McpSchema.Resource getLogsResourceSpec() { - var audience = java.util.List.of(io.modelcontextprotocol.spec.McpSchema.Role.USER); - var annotations = new io.modelcontextprotocol.spec.McpSchema.Annotations(audience, 1.5D, "1"); - return new io.modelcontextprotocol.spec.McpSchema.Resource( - "file:///logs/app.log", - "Application Logs", - "Logs Title.", - "Log description Suspendisse potenti.", - io.jooby.MediaType.byFileExtension("file:///logs/app.log", "text/plain").getValue(), - 1024L, - annotations, - null); - } - - private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getLogs( - io.modelcontextprotocol.server.McpSyncServerExchange exchange, - io.modelcontextprotocol.common.McpTransportContext transportContext, - io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest req) { - var ctx = - exchange != null - ? (io.jooby.Context) exchange.transportContext().get("CTX") - : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); - var args = java.util.Collections.emptyMap(); - var c = this.factory.apply(ctx); - var result = c.getLogs(); - return new io.jooby.mcp.McpResult(this.json).toResourceResult(req.uri(), result); - } - - private io.modelcontextprotocol.spec.McpSchema.ResourceTemplate - getUserProfileResourceTemplateSpec() { - return new io.modelcontextprotocol.spec.McpSchema.ResourceTemplate( - "file:///users/{id}/{name}/profile", - "getUserProfile", - "Resource Template.", - null, - "application/json", - null, - null); - } - - private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getUserProfile( - io.modelcontextprotocol.server.McpSyncServerExchange exchange, - io.modelcontextprotocol.common.McpTransportContext transportContext, - io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest req) { - var ctx = - exchange != null - ? (io.jooby.Context) exchange.transportContext().get("CTX") - : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); - var uri = req.uri(); - var manager = - new io.modelcontextprotocol.util.DefaultMcpUriTemplateManager( - "file:///users/{id}/{name}/profile"); - var args = new java.util.HashMap(); - args.putAll(manager.extractVariableValues(uri)); - var c = this.factory.apply(ctx); - var raw_id = args.get("id"); - var id = raw_id != null ? raw_id.toString() : null; - var raw_name = args.get("name"); - var name = raw_name != null ? raw_name.toString() : null; - var result = c.getUserProfile(id, name); - return new io.jooby.mcp.McpResult(this.json).toResourceResult(req.uri(), result); - } - - private io.modelcontextprotocol.spec.McpSchema.CompleteResult getUserProfileCompletionHandler( - io.modelcontextprotocol.server.McpSyncServerExchange exchange, - io.modelcontextprotocol.common.McpTransportContext transportContext, - io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) { - var ctx = - exchange != null - ? (io.jooby.Context) exchange.transportContext().get("CTX") - : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); - var c = this.factory.apply(ctx); - var targetArg = req.argument() != null ? req.argument().name() : ""; - var typedValue = req.argument() != null ? req.argument().value() : ""; - return switch (targetArg) { - case "id" -> { - var result = c.completeUserId(typedValue); - yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); - } - case "name" -> { - var result = c.completeUserName(typedValue); - yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); - } - default -> new io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of()); - }; - } - - private io.modelcontextprotocol.spec.McpSchema.CompleteResult reviewCodeCompletionHandler( - io.modelcontextprotocol.server.McpSyncServerExchange exchange, - io.modelcontextprotocol.common.McpTransportContext transportContext, - io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) { - var ctx = - exchange != null - ? (io.jooby.Context) exchange.transportContext().get("CTX") - : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); - var c = this.factory.apply(ctx); - var targetArg = req.argument() != null ? req.argument().name() : ""; - var typedValue = req.argument() != null ? req.argument().value() : ""; - return switch (targetArg) { - case "language" -> { - var result = c.reviewCodelanguage(typedValue); - yield new io.jooby.mcp.McpResult(this.json).toCompleteResult(result); - } - default -> new io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of()); - }; - } -} diff --git a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java index 6c0cd4b8d4..61b5cb10cc 100644 --- a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java +++ b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java @@ -17,7 +17,6 @@ public void shouldGenerateMcpServer() throws Exception { new ProcessorRunner(new ExampleServer()) .withMcpCode( source -> { - System.out.println(source); assertThat(source) .isEqualToNormalizingNewlines( """ @@ -64,16 +63,16 @@ public String serverKey() { @Override public java.util.List completions() { var completions = new java.util.ArrayList(); - completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.ResourceReference("file:///users/{id}/{name}/profile"), (exchange, req) -> this.getUserProfileCompletionHandler(exchange, null, req))); completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), (exchange, req) -> this.reviewCodeCompletionHandler(exchange, null, req))); + completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.ResourceReference("file:///users/{id}/{name}/profile"), (exchange, req) -> this.getUserProfileCompletionHandler(exchange, null, req))); return completions; } @Override public java.util.List statelessCompletions() { var completions = new java.util.ArrayList(); - completions.add(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.ResourceReference("file:///users/{id}/{name}/profile"), (ctx, req) -> this.getUserProfileCompletionHandler(null, ctx, req))); completions.add(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), (ctx, req) -> this.reviewCodeCompletionHandler(null, ctx, req))); + completions.add(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.ResourceReference("file:///users/{id}/{name}/profile"), (ctx, req) -> this.getUserProfileCompletionHandler(null, ctx, req))); return completions; } @@ -124,10 +123,10 @@ private io.modelcontextprotocol.spec.McpSchema.CallToolResult add(io.modelcontex var c = this.factory.apply(ctx); var raw_a = args.get("a"); if (raw_a == null) throw new IllegalArgumentException("Missing req param: a"); - var a = raw_a instanceof Number ? ((Number) raw_a).intValue() : Integer.parseInt(raw_a.toString()); + var a = ((Number) raw_a).intValue(); var raw_b = args.get("b"); if (raw_b == null) throw new IllegalArgumentException("Missing req param: b"); - var b = raw_b instanceof Number ? ((Number) raw_b).intValue() : Integer.parseInt(raw_b.toString()); + var b = ((Number) raw_b).intValue(); var result = c.add(a, b); return new io.jooby.mcp.McpResult(this.json).toCallToolResult(result, false); } diff --git a/modules/jooby-bom/pom.xml b/modules/jooby-bom/pom.xml index efdacdb619..6e1256a46a 100644 --- a/modules/jooby-bom/pom.xml +++ b/modules/jooby-bom/pom.xml @@ -165,6 +165,11 @@ jooby-jasypt ${project.version} + + io.jooby + jooby-javadoc + ${project.version} + io.jooby jooby-jdbi @@ -220,6 +225,21 @@ jooby-maven-plugin ${project.version} + + io.jooby + jooby-mcp + ${project.version} + + + io.jooby + jooby-mcp-jackson2 + ${project.version} + + + io.jooby + jooby-mcp-jackson3 + ${project.version} + io.jooby jooby-metrics diff --git a/modules/jooby-mcp-jackson2/pom.xml b/modules/jooby-mcp-jackson2/pom.xml index 25c3f21105..d097cd2b72 100644 --- a/modules/jooby-mcp-jackson2/pom.xml +++ b/modules/jooby-mcp-jackson2/pom.xml @@ -28,4 +28,20 @@ 4.38.0 + + + + org.apache.maven.plugins + maven-jar-plugin + ${maven-jar-plugin.version} + + + + io.jooby.mcp.jackson2 + + + + + + diff --git a/modules/jooby-mcp-jackson3/pom.xml b/modules/jooby-mcp-jackson3/pom.xml index ad09bb1782..bab37e6c86 100644 --- a/modules/jooby-mcp-jackson3/pom.xml +++ b/modules/jooby-mcp-jackson3/pom.xml @@ -28,4 +28,20 @@ 5.0.0 + + + + org.apache.maven.plugins + maven-jar-plugin + ${maven-jar-plugin.version} + + + + io.jooby.mcp.jackson3 + + + + + + diff --git a/modules/jooby-mcp/pom.xml b/modules/jooby-mcp/pom.xml index e169662125..36cb165790 100644 --- a/modules/jooby-mcp/pom.xml +++ b/modules/jooby-mcp/pom.xml @@ -23,4 +23,21 @@ mcp-core + + + + + org.apache.maven.plugins + maven-jar-plugin + ${maven-jar-plugin.version} + + + + io.jooby.mcp + + + + + + diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/AbstractMcpTransport.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/AbstractMcpTransport.java similarity index 95% rename from modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/AbstractMcpTransport.java rename to modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/AbstractMcpTransport.java index b9c75b74c7..2ea6703138 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/AbstractMcpTransport.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/AbstractMcpTransport.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.mcp.transport; +package io.jooby.internal.mcp.transport; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/AbstractMcpTransportProvider.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/AbstractMcpTransportProvider.java similarity index 98% rename from modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/AbstractMcpTransportProvider.java rename to modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/AbstractMcpTransportProvider.java index d468e7c66f..f22bb93d58 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/AbstractMcpTransportProvider.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/AbstractMcpTransportProvider.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.mcp.transport; +package io.jooby.internal.mcp.transport; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/SendError.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/SendError.java similarity index 99% rename from modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/SendError.java rename to modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/SendError.java index 997a80c2f8..1e10cd33ac 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/SendError.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/SendError.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.mcp.transport; +package io.jooby.internal.mcp.transport; import java.util.List; diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/SseTransportProvider.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/SseTransportProvider.java similarity index 95% rename from modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/SseTransportProvider.java rename to modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/SseTransportProvider.java index af9735e0d0..9af8569ae8 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/SseTransportProvider.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/SseTransportProvider.java @@ -3,9 +3,9 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.mcp.transport; +package io.jooby.internal.mcp.transport; -import static io.jooby.mcp.transport.TransportConstants.*; +import static io.jooby.internal.mcp.transport.TransportConstants.*; import java.io.IOException; @@ -94,9 +94,7 @@ private Object handleMessage(Context ctx) { return session .handle(message) - .contextWrite( - reactorCtx -> - reactorCtx.put(McpTransportContext.KEY, transportContext).put("CTX", ctx)) + .contextWrite(reactorCtx -> reactorCtx.put(McpTransportContext.KEY, transportContext)) .then(Mono.just((Object) StatusCode.OK)) .onErrorResume( error -> { diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/StatelessTransportProvider.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/StatelessTransportProvider.java similarity index 97% rename from modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/StatelessTransportProvider.java rename to modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/StatelessTransportProvider.java index 353532111c..e06cf009fe 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/StatelessTransportProvider.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/StatelessTransportProvider.java @@ -3,9 +3,9 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.mcp.transport; +package io.jooby.internal.mcp.transport; -import static io.jooby.mcp.transport.TransportConstants.TEXT_EVENT_STREAM; +import static io.jooby.internal.mcp.transport.TransportConstants.TEXT_EVENT_STREAM; import static io.modelcontextprotocol.spec.McpSchema.ErrorCodes.INVALID_REQUEST; import java.io.IOException; diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/StreamableTransportProvider.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/StreamableTransportProvider.java similarity index 98% rename from modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/StreamableTransportProvider.java rename to modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/StreamableTransportProvider.java index ec84df5c36..478560c17a 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/StreamableTransportProvider.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/StreamableTransportProvider.java @@ -3,9 +3,9 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.mcp.transport; +package io.jooby.internal.mcp.transport; -import static io.jooby.mcp.transport.TransportConstants.*; +import static io.jooby.internal.mcp.transport.TransportConstants.*; import static io.modelcontextprotocol.spec.McpSchema.ErrorCodes.INVALID_REQUEST; import java.io.IOException; @@ -102,8 +102,7 @@ private Context handleGet(Context ctx) { session .replay(lastId) .contextWrite( - reactorCtx -> - reactorCtx.put(McpTransportContext.KEY, transportContext).put("CTX", ctx)) + reactorCtx -> reactorCtx.put(McpTransportContext.KEY, transportContext)) .concatMap( message -> sessionTransport diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/TransportConstants.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/TransportConstants.java similarity index 91% rename from modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/TransportConstants.java rename to modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/TransportConstants.java index bba1748ff8..c4e65ace70 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/TransportConstants.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/TransportConstants.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.mcp.transport; +package io.jooby.internal.mcp.transport; import io.jooby.MediaType; diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/WebSocketTransportProvider.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/WebSocketTransportProvider.java similarity index 95% rename from modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/WebSocketTransportProvider.java rename to modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/WebSocketTransportProvider.java index dd30c1b26d..49992883ba 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/transport/WebSocketTransportProvider.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/WebSocketTransportProvider.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.mcp.transport; +package io.jooby.internal.mcp.transport; import java.io.IOException; @@ -79,9 +79,7 @@ private void handleMessage(WebSocket ws, WebSocketMessage msg) { sessions .get(sessionId) .handle(message) - .contextWrite( - reactorCtx -> - reactorCtx.put(McpTransportContext.KEY, transportContext).put("CTX", ctx)) + .contextWrite(reactorCtx -> reactorCtx.put(McpTransportContext.KEY, transportContext)) .subscribe( null, error -> diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInspectorModule.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInspectorModule.java new file mode 100644 index 0000000000..6f8ea30e9b --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInspectorModule.java @@ -0,0 +1,204 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.mcp; + +import java.util.List; + +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.*; +import io.jooby.exception.RegistryException; +import io.jooby.exception.StartupException; +import io.jooby.internal.mcp.McpServerConfig; + +/** + * MCP Inspector module for Jooby. + * + *

The MCP Inspector module provides a web-based interface for inspecting and interacting with + * local MCP server running on the same app. It serves a frontend application that allows users to + * connect to MCP servers, view their capabilities, and test various protocol features. + * + *

Usage

+ * + *

Add the module to your application: + * + *

{@code
+ * {
+ *   install(new McpInspectorModule());
+ * }
+ * }
+ * + * All available configurations example: + * + *
{@code
+ * {
+ *   install(new McpInspectorModule()
+ *      .path("/inspector")               // Optional, default is /mcp-inspector
+ *      .autoConnect(false)               // Optional, default is true
+ *      .defaultServer("my-mcp-server")   // Optional, default is the first configured MCP server
+ *   );
+ * }
+ * }
+ * + *

Configuration

+ * + *

The module requires at least one MCP server to be configured in your Jooby application. + * + *

Features

+ * + *
    + *
  • Serves a web-based MCP Inspector UI + *
  • Automatically configures the inspector to connect to the local MCP server with respect to + * transport and endpoint + *
  • Supports only direct connection and enables it automatically when the page loads + *
+ * + * @author kliushnichenko + * @since 1.9.0 + */ +public class McpInspectorModule implements Extension { + + private static final String DIST = + "https://cdn.jsdelivr.net/npm/@modelcontextprotocol/inspector-client@0.20.0/dist"; + + private static final String INDEX_HTML_TEMPLATE = + """ + + + + + + + MCP Inspector + + + + + +
+ + %s + + """; + + private static final String AUTO_CONNECT_SCRIPT = + """ + \ + """; + + private static final String DEFAULT_ENDPOINT = "/mcp-inspector"; + public static final String X_FORWARDED_PROTO = "X-Forwarded-Proto"; + + private String inspectorEndpoint = DEFAULT_ENDPOINT; + private boolean autoConnect = true; + private String defaultServer; + private McpServerConfig mcpSrvConfig; + private String indexHtml; + + public McpInspectorModule path(@NonNull String inspectorEndpoint) { + this.inspectorEndpoint = inspectorEndpoint; + return this; + } + + public McpInspectorModule autoConnect(boolean autoConnect) { + this.autoConnect = autoConnect; + return this; + } + + public McpInspectorModule defaultServer(@NonNull String mcpServerName) { + this.defaultServer = mcpServerName; + return this; + } + + @Override + public void install(@NonNull Jooby app) { + this.indexHtml = buildIndexHtml(); + this.mcpSrvConfig = resolveMcpServerConfig(app); + + app.assets("/mcp-inspector/static/*", "/mcpInspector/assets/"); + + app.get(inspectorEndpoint, ctx -> ctx.setResponseType(MediaType.html).render(this.indexHtml)); + + app.get( + "/mcp-inspector/config", + ctx -> { + var location = resolveLocation(ctx); + var configJson = buildConfigJson(mcpSrvConfig, location); + return ctx.setResponseType(MediaType.json).render(configJson); + }); + } + + private String buildIndexHtml() { + var script = this.autoConnect ? AUTO_CONNECT_SCRIPT : ""; + return INDEX_HTML_TEMPLATE.formatted(DIST, DIST, DIST, script); + } + + private String resolveLocation(Context ctx) { + var scheme = resolveSchema(ctx); + if (ctx.getPort() == 80) { + return scheme + "://" + ctx.getHost(); + } else { + return scheme + "://" + ctx.getHostAndPort(); + } + } + + private String resolveSchema(Context ctx) { + if (ctx.header(X_FORWARDED_PROTO).isPresent()) { + return ctx.header(X_FORWARDED_PROTO).value(); + } else { + return ctx.getScheme(); + } + } + + private McpServerConfig resolveMcpServerConfig(Jooby app) { + List srvConfigs; + try { + srvConfigs = app.getServices().get(Reified.list(McpServerConfig.class)); + } catch (RegistryException ex) { + throw new StartupException( + "MCP Inspector module requires at least one MCP server to be configured."); + } + + if (defaultServer != null) { + return srvConfigs.stream() + .filter(config -> config.getName().equals(defaultServer)) + .findFirst() + .orElseThrow( + () -> + new StartupException("MCP server named '%s' not found".formatted(defaultServer))); + } + + return srvConfigs.get(0); + } + + private String buildConfigJson(McpServerConfig config, String location) { + var endpoint = resolveEndpoint(config); + var transport = config.getTransport(); + return """ + { + "defaultEnvironment": { + }, + "defaultCommand": "", + "defaultArgs": "", + "defaultTransport": "%s", + "defaultServerUrl": "%s%s" + } + """ + .formatted(transport.getValue(), location, endpoint); + } + + private String resolveEndpoint(McpServerConfig config) { + if (config.isSseTransport()) { + return config.getSseEndpoint(); + } else { + return config.getMcpEndpoint(); + } + } + + @Override + public boolean lateinit() { + return true; + } +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java index 134183e9cc..8ae444af08 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java @@ -15,12 +15,13 @@ import io.jooby.Context; import io.jooby.Extension; import io.jooby.Jooby; +import io.jooby.ServiceKey; import io.jooby.exception.StartupException; import io.jooby.internal.mcp.McpServerConfig; -import io.jooby.mcp.transport.SseTransportProvider; -import io.jooby.mcp.transport.StatelessTransportProvider; -import io.jooby.mcp.transport.StreamableTransportProvider; -import io.jooby.mcp.transport.WebSocketTransportProvider; +import io.jooby.internal.mcp.transport.SseTransportProvider; +import io.jooby.internal.mcp.transport.StatelessTransportProvider; +import io.jooby.internal.mcp.transport.StreamableTransportProvider; +import io.jooby.internal.mcp.transport.WebSocketTransportProvider; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.server.*; @@ -111,9 +112,9 @@ */ public class McpModule implements Extension { - protected static final McpTransportContextExtractor CTX_EXTRACTOR = + private static final McpTransportContextExtractor CTX_EXTRACTOR = ctx -> { - var transportContext = Map.of("HEADERS", ctx.headerMap()); + var transportContext = Map.of("HEADERS", ctx.headerMap(), "CTX", ctx); return McpTransportContext.create(transportContext); }; @@ -148,6 +149,9 @@ public void install(@NonNull Jooby app) { // Boot everything for (var serverEntry : mcpServiceMap.entrySet()) { var mcpConfig = mcpServerConfig(app, serverEntry.getKey()); + // Internal usage only, required by mcp-inspector + services.listOf(McpServerConfig.class).add(mcpConfig); + var capabilities = new McpSchema.ServerCapabilities.Builder(); serverEntry.getValue().forEach(it -> it.capabilities(capabilities)); @@ -161,9 +165,16 @@ public void install(@NonNull Jooby app) { .capabilities(capabilities.build()) .instructions(mcpConfig.getInstructions()) .build(); + // install services serverEntry .getValue() .forEach(throwingConsumer(service -> service.install(app, statelessServer))); + // bind registry + services.putIfAbsent(McpStatelessSyncServer.class, statelessServer); + services.put( + ServiceKey.key(McpStatelessSyncServer.class, serverEntry.getKey()), statelessServer); + services.listOf(McpStatelessSyncServer.class).add(statelessServer); + app.onStop(statelessServer::close); } else { // Stupid MCP types, but it's the only way to make it work. @@ -189,9 +200,15 @@ public void install(@NonNull Jooby app) { .capabilities(capabilities.build()) .instructions(mcpConfig.getInstructions()) .build(); + // install service serverEntry .getValue() .forEach(throwingConsumer(service -> service.install(app, syncServer))); + // bind registry + services.putIfAbsent(McpSyncServer.class, syncServer); + services.put(ServiceKey.key(McpSyncServer.class, serverEntry.getKey()), syncServer); + services.listOf(McpSyncServer.class).add(syncServer); + app.onStop(syncServer::close); } } @@ -234,7 +251,7 @@ public enum Transport { SSE("sse"), STREAMABLE_HTTP("streamable-http"), STATELESS_STREAMABLE_HTTP("stateless-streamable-http"), - WEBSOCKET("websocket"); + WEBSOCKET("web-socket"); private final String value; @@ -243,7 +260,7 @@ public enum Transport { } public static Transport of(String value) { - for (Transport transport : values()) { + for (var transport : values()) { if (transport.value.equalsIgnoreCase(value)) { return transport; } diff --git a/modules/jooby-mcp/src/main/resources/mcpInspector/assets/autoConnectScript-B8iPFz0O.js b/modules/jooby-mcp/src/main/resources/mcpInspector/assets/autoConnectScript-B8iPFz0O.js new file mode 100644 index 0000000000..150a32f887 --- /dev/null +++ b/modules/jooby-mcp/src/main/resources/mcpInspector/assets/autoConnectScript-B8iPFz0O.js @@ -0,0 +1,14 @@ +const connectBtnObserver = new MutationObserver(() => { + const btn = [...document.querySelectorAll('button')] + .find(el => el.textContent.trim() === 'Connect'); + if (btn) { + connectBtnObserver.disconnect(); + + setTimeout(() => { + btn.click(); + console.log('Auto-connecting to MCP server...'); + }, 500); + } +}); + +connectBtnObserver.observe(document.body, { childList: true, subtree: true }); \ No newline at end of file diff --git a/modules/jooby-mcp/src/main/resources/mcpInspector/assets/initScript-B8iPFz0O.js b/modules/jooby-mcp/src/main/resources/mcpInspector/assets/initScript-B8iPFz0O.js new file mode 100644 index 0000000000..cd02c54354 --- /dev/null +++ b/modules/jooby-mcp/src/main/resources/mcpInspector/assets/initScript-B8iPFz0O.js @@ -0,0 +1,75 @@ +const INSPECTOR_CONFIG_KEY = "inspectorConfig_v1"; + +const DEFAULT_INSPECTOR_CONFIG = { + MCP_SERVER_REQUEST_TIMEOUT: { + label: "Request Timeout", + description: "Client-side timeout (ms) - Inspector will cancel requests after this time", + value: 3e5, + // 5 minutes - increased to support elicitation and other long-running tools + is_session_item: false + }, + MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS: { + label: "Reset Timeout on Progress", + description: "Reset timeout on progress notifications", + value: true, + is_session_item: false + }, + MCP_REQUEST_MAX_TOTAL_TIMEOUT: { + label: "Maximum Total Timeout", + description: "Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)", + value: 6e4, + is_session_item: false + }, + MCP_PROXY_FULL_ADDRESS: { + label: "Inspector Proxy Address", + description: "Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577", + value: "", + is_session_item: false + }, + MCP_PROXY_AUTH_TOKEN: { + label: "Proxy Session Token", + description: "Session token for authenticating with the MCP Proxy Server (displayed in proxy console on startup)", + value: "", + is_session_item: true + }, + MCP_TASK_TTL: { + label: "Task TTL", + description: + "Default Time-to-Live (TTL) in milliseconds for newly created tasks", + value: 60000, + is_session_item: false + } +}; + +function patchMcpProxyAddress(storageKey, newAddress) { + const config = localStorage.getItem(storageKey); + if (!config) { + console.warn(`localStorage key "${storageKey}" not found. Setting default values...`); + const data = Object.assign({}, DEFAULT_INSPECTOR_CONFIG); + data.MCP_PROXY_FULL_ADDRESS.value = newAddress; + localStorage.setItem(storageKey, JSON.stringify(data)); + return; + } + + try { + const data = JSON.parse(config); + + if (!data.MCP_PROXY_FULL_ADDRESS) { + console.warn("MCP_PROXY_FULL_ADDRESS not found in stored object"); + return; + } + + data.MCP_PROXY_FULL_ADDRESS.value = newAddress; + localStorage.setItem(storageKey, JSON.stringify(data)); + } catch (e) { + console.error("Failed to parse localStorage JSON:", e); + } +} + +function patch_mcp_inspector_config() { + localStorage.setItem("lastConnectionType", "direct"); + const url = location.origin + "/mcp-inspector"; + patchMcpProxyAddress(INSPECTOR_CONFIG_KEY, url); +} + +patch_mcp_inspector_config(); \ No newline at end of file diff --git a/pom.xml b/pom.xml index 84bdee1467..d5ce0cadef 100644 --- a/pom.xml +++ b/pom.xml @@ -266,7 +266,7 @@ io.modelcontextprotocol.sdk mcp-bom - 1.1.0 + 1.1.1 pom import From 40a6e9b3d065bc68b3ec19788d455f9531a13539 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sun, 29 Mar 2026 19:50:46 -0300 Subject: [PATCH 29/37] refactor(mcp): simplify transport context extraction in code generator Since the routing lambdas now directly inject the transport context for both stateful and stateless flows, the `transportContext` parameter is guaranteed to be non-null in the generated handler methods. This commit cleans up the APT generator (`McpRoute`, `McpRouter`) by: * Removing redundant null-checks and ternary operators associated with the transport context. * Eliminating Kotlin safe-calls (`?.`) in method signatures and variable extractions. * Streamlining Jooby `Context` extraction to use direct, safe casts (e.g., `(io.jooby.Context) transportContext.get("CTX")`). * Significantly reducing the boilerplate and branching in all generated tool, prompt, resource, and completion handlers. --- .../java/io/jooby/internal/apt/McpRoute.java | 39 ++++------------- .../java/io/jooby/internal/apt/McpRouter.java | 43 ++++++++++++------- .../src/test/java/tests/i3830/Issue3830.java | 24 +++++------ .../main/java/io/jooby/mcp/McpHandler.java | 15 +++++++ .../java/io/jooby/i3830/CalculatorTools.java | 5 --- 5 files changed, 63 insertions(+), 63 deletions(-) create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/mcp/McpHandler.java diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java index 957f6bd884..18629e5b8b 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java @@ -708,18 +708,15 @@ public List generateMcpHandlerMethod(boolean kt) { "private fun ", handlerName, "(exchange: io.modelcontextprotocol.server.McpSyncServerExchange?, transportContext:" - + " io.modelcontextprotocol.common.McpTransportContext?, req:" + + " io.modelcontextprotocol.common.McpTransportContext, req:" // Removed '?' + " io.modelcontextprotocol.spec.McpSchema.", reqType, "): io.modelcontextprotocol.spec.McpSchema.", resType, " {")); + buffer.add( - statement( - indent(6), - "val ctx =" - + " exchange?.transportContext()?.get(io.jooby.Context::class.java.name)" - + " ?: transportContext?.get(io.jooby.Context::class.java.name)")); + statement(indent(6), "val ctx = transportContext.get(\"CTX\") as? io.jooby.Context")); } else { buffer.add( statement( @@ -729,16 +726,16 @@ public List generateMcpHandlerMethod(boolean kt) { " ", handlerName, "(io.modelcontextprotocol.server.McpSyncServerExchange exchange," - + " io.modelcontextprotocol.common.McpTransportContext transportContext," + + " io.modelcontextprotocol.common.McpTransportContext" + + " transportContext," // Guaranteed non-null + " io.modelcontextprotocol.spec.McpSchema.", reqType, " req) {")); + buffer.add( statement( indent(6), - "var ctx = exchange != null ? (io.jooby.Context)" - + " exchange.transportContext().get(\"CTX\") : (transportContext != null ?" - + " (io.jooby.Context) transportContext.get(\"CTX\") : null)", + "var ctx = (io.jooby.Context) transportContext.get(\"CTX\")", semicolon(kt))); } @@ -809,26 +806,8 @@ public List generateMcpHandlerMethod(boolean kt) { javaParamNames.add(javaName); if (type.equals("io.jooby.Context") - || type.equals("io.modelcontextprotocol.server.McpSyncServerExchange")) { - continue; - } - if (type.equals("io.modelcontextprotocol.common.McpTransportContext")) { - if (kt) { - buffer.add( - statement( - indent(6), - "val ", - javaName, - " = exchange?.transportContext() ?: transportContext")); - } else { - buffer.add( - statement( - indent(6), - "var ", - javaName, - " = exchange != null ? exchange.transportContext() : transportContext", - semicolon(kt))); - } + || type.equals("io.modelcontextprotocol.server.McpSyncServerExchange") + || type.equals("io.modelcontextprotocol.common.McpTransportContext")) { continue; } else if (type.equals("io.modelcontextprotocol.spec.McpSchema." + reqType)) { buffer.add(statement(indent(6), kt ? "val " : "var ", javaName, " = req", semicolon(kt))); diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java index 5f8886283e..38aa277462 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java @@ -277,8 +277,13 @@ public String toSourceCode(boolean kt) throws IOException { var handlerName = findTargetMethodName(ref) + "CompletionHandler"; lambda = kt - ? "{ exchange, req -> this." + handlerName + "(exchange, null, req) }" - : "(exchange, req) -> this." + handlerName + "(exchange, null, req)"; + ? "{ exchange, req -> this." + + handlerName + + "(exchange, exchange.transportContext(), req) }" + : "(exchange, req) -> this." + + handlerName + + "(exchange, exchange.transportContext(), req)"; + } else { // Fallback: Return an empty completion result safely lambda = @@ -464,10 +469,14 @@ public String toSourceCode(boolean kt) throws IOException { kt ? (isStateless ? "{ ctx, req -> this." + methodName + "(null, ctx, req) }" - : "{ exchange, req -> this." + methodName + "(exchange, null, req) }") + : "{ exchange, req -> this." + + methodName + + "(exchange, exchange.transportContext(), req) }") : (isStateless ? "(ctx, req) -> this." + methodName + "(null, ctx, req)" - : "(exchange, req) -> this." + methodName + "(exchange, null, req)"); + : "(exchange, req) -> this." + + methodName + + "(exchange, exchange.transportContext(), req)"); if (route.isMcpTool()) { // Removed "mapper" from defArgs @@ -584,15 +593,19 @@ public String toSourceCode(boolean kt) throws IOException { "private fun ", handlerName, "(exchange: io.modelcontextprotocol.server.McpSyncServerExchange?," - + " transportContext: io.modelcontextprotocol.common.McpTransportContext?, req:" + + " transportContext:" + + " io.modelcontextprotocol.common.McpTransportContext," + + " req:" // Removed '?' + " io.modelcontextprotocol.spec.McpSchema.CompleteRequest):" + " io.modelcontextprotocol.spec.McpSchema.CompleteResult {")); + + // Direct extraction, no fallback needed buffer.append( statement( indent(6), "val ctx =" - + " exchange?.transportContext()?.get(io.jooby.Context::class.java.name)" - + " ?: transportContext?.get(io.jooby.Context::class.java.name)")); + + " transportContext.get(io.jooby.Context::class.java.name)")); + buffer.append(statement(indent(6), "val c = this.factory.apply(ctx)")); buffer.append(statement(indent(6), "val targetArg = req.argument()?.name() ?: \"\"")); buffer.append(statement(indent(6), "val typedValue = req.argument()?.value() ?: \"\"")); @@ -604,15 +617,17 @@ public String toSourceCode(boolean kt) throws IOException { "private io.modelcontextprotocol.spec.McpSchema.CompleteResult ", handlerName, "(io.modelcontextprotocol.server.McpSyncServerExchange exchange," - + " io.modelcontextprotocol.common.McpTransportContext transportContext," + + " io.modelcontextprotocol.common.McpTransportContext" + + " transportContext," // Guaranteed non-null + " io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) {")); + + // Direct extraction, no ternary operator buffer.append( statement( indent(6), - "var ctx = exchange != null ? (io.jooby.Context)" - + " exchange.transportContext().get(\"CTX\") : (transportContext != null ?" - + " (io.jooby.Context) transportContext.get(\"CTX\") : null)", + "var ctx = (io.jooby.Context) transportContext.get(\"CTX\")", semicolon(kt))); + buffer.append(statement(indent(6), "var c = this.factory.apply(ctx)", semicolon(kt))); buffer.append( statement( @@ -638,11 +653,7 @@ public String toSourceCode(boolean kt) throws IOException { } else if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange")) { invokeArgs.add("exchange"); } else if (type.equals("io.modelcontextprotocol.common.McpTransportContext")) { - if (kt) { - invokeArgs.add("exchange?.transportContext() ?: transportContext"); - } else { - invokeArgs.add("exchange != null ? exchange.transportContext() : transportContext"); - } + invokeArgs.add("transportContext"); } else { targetArgName = param.getMcpName(); invokeArgs.add("typedValue"); diff --git a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java index 61b5cb10cc..379282fbf0 100644 --- a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java +++ b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java @@ -63,8 +63,8 @@ public String serverKey() { @Override public java.util.List completions() { var completions = new java.util.ArrayList(); - completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), (exchange, req) -> this.reviewCodeCompletionHandler(exchange, null, req))); - completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.ResourceReference("file:///users/{id}/{name}/profile"), (exchange, req) -> this.getUserProfileCompletionHandler(exchange, null, req))); + completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), (exchange, req) -> this.reviewCodeCompletionHandler(exchange, exchange.transportContext(), req))); + completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.ResourceReference("file:///users/{id}/{name}/profile"), (exchange, req) -> this.getUserProfileCompletionHandler(exchange, exchange.transportContext(), req))); return completions; } @@ -81,10 +81,10 @@ public void install(io.jooby.Jooby app, io.modelcontextprotocol.server.McpSyncSe this.json = app.require(io.modelcontextprotocol.json.McpJsonMapper.class); var schemaGenerator = app.require(com.github.victools.jsonschema.generator.SchemaGenerator.class); - server.addTool(new io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification(addToolSpec(schemaGenerator), (exchange, req) -> this.add(exchange, null, req))); - server.addPrompt(new io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification(reviewCodePromptSpec(), (exchange, req) -> this.reviewCode(exchange, null, req))); - server.addResource(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification(getLogsResourceSpec(), (exchange, req) -> this.getLogs(exchange, null, req))); - server.addResourceTemplate(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceTemplateSpecification(getUserProfileResourceTemplateSpec(), (exchange, req) -> this.getUserProfile(exchange, null, req))); + server.addTool(new io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification(addToolSpec(schemaGenerator), (exchange, req) -> this.add(exchange, exchange.transportContext(), req))); + server.addPrompt(new io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification(reviewCodePromptSpec(), (exchange, req) -> this.reviewCode(exchange, exchange.transportContext(), req))); + server.addResource(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification(getLogsResourceSpec(), (exchange, req) -> this.getLogs(exchange, exchange.transportContext(), req))); + server.addResourceTemplate(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceTemplateSpecification(getUserProfileResourceTemplateSpec(), (exchange, req) -> this.getUserProfile(exchange, exchange.transportContext(), req))); } @Override @@ -118,7 +118,7 @@ private io.modelcontextprotocol.spec.McpSchema.Tool addToolSpec(com.github.victo } private io.modelcontextprotocol.spec.McpSchema.CallToolResult add(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.CallToolRequest req) { - var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var ctx = (io.jooby.Context) transportContext.get("CTX"); var args = req.arguments() != null ? req.arguments() : java.util.Collections.emptyMap(); var c = this.factory.apply(ctx); var raw_a = args.get("a"); @@ -139,7 +139,7 @@ private io.modelcontextprotocol.spec.McpSchema.Prompt reviewCodePromptSpec() { } private io.modelcontextprotocol.spec.McpSchema.GetPromptResult reviewCode(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.GetPromptRequest req) { - var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var ctx = (io.jooby.Context) transportContext.get("CTX"); var args = req.arguments() != null ? req.arguments() : java.util.Collections.emptyMap(); var c = this.factory.apply(ctx); var raw_language = args.get("language"); @@ -157,7 +157,7 @@ private io.modelcontextprotocol.spec.McpSchema.Resource getLogsResourceSpec() { } private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getLogs(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest req) { - var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var ctx = (io.jooby.Context) transportContext.get("CTX"); var args = java.util.Collections.emptyMap(); var c = this.factory.apply(ctx); var result = c.getLogs(); @@ -169,7 +169,7 @@ private io.modelcontextprotocol.spec.McpSchema.ResourceTemplate getUserProfileRe } private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getUserProfile(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest req) { - var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var ctx = (io.jooby.Context) transportContext.get("CTX"); var uri = req.uri(); var manager = new io.modelcontextprotocol.util.DefaultMcpUriTemplateManager("file:///users/{id}/{name}/profile"); var args = new java.util.HashMap(); @@ -184,7 +184,7 @@ private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getUserProfile } private io.modelcontextprotocol.spec.McpSchema.CompleteResult getUserProfileCompletionHandler(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) { - var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var ctx = (io.jooby.Context) transportContext.get("CTX"); var c = this.factory.apply(ctx); var targetArg = req.argument() != null ? req.argument().name() : ""; var typedValue = req.argument() != null ? req.argument().value() : ""; @@ -202,7 +202,7 @@ private io.modelcontextprotocol.spec.McpSchema.CompleteResult getUserProfileComp } private io.modelcontextprotocol.spec.McpSchema.CompleteResult reviewCodeCompletionHandler(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) { - var ctx = exchange != null ? (io.jooby.Context) exchange.transportContext().get("CTX") : (transportContext != null ? (io.jooby.Context) transportContext.get("CTX") : null); + var ctx = (io.jooby.Context) transportContext.get("CTX"); var c = this.factory.apply(ctx); var targetArg = req.argument() != null ? req.argument().name() : ""; var typedValue = req.argument() != null ? req.argument().value() : ""; diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpHandler.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpHandler.java new file mode 100644 index 0000000000..47771ce736 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpHandler.java @@ -0,0 +1,15 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.mcp; + +import io.jooby.SneakyThrows; + +public class McpHandler { + + public static R invoke(SneakyThrows.Supplier action) { + return action.get(); + } +} diff --git a/tests/src/test/java/io/jooby/i3830/CalculatorTools.java b/tests/src/test/java/io/jooby/i3830/CalculatorTools.java index 84a6148cbe..33bbf26b65 100644 --- a/tests/src/test/java/io/jooby/i3830/CalculatorTools.java +++ b/tests/src/test/java/io/jooby/i3830/CalculatorTools.java @@ -17,8 +17,6 @@ /** A collection of tools, prompts, and resources exposed to the LLM via MCP. */ public class CalculatorTools { - // --- TOOLS --- - /** * Adds two integers together and returns the result. * @@ -31,7 +29,6 @@ public int add(int a, int b) { return a + b; } - // --- PROMPTS --- @McpPrompt(name = "math_tutor", description = "A prompt to initiate a math tutoring session") public String mathTutor(String topic) { return "You are a helpful math tutor. Please explain the concept of " @@ -39,7 +36,6 @@ public String mathTutor(String topic) { + " step by step."; } - // --- RESOURCES --- @McpResource( uri = "calculator://manual/usage", name = "Calculator Manual", @@ -56,7 +52,6 @@ public String history(String user) { return "History for " + user + ":\n5 + 10 = 15\n2 * 4 = 8"; } - // --- COMPLETIONS --- @McpCompletion(ref = "calculator://history/{user}") public List historyCompletions(String user) { // In a real app, this would query a database for active usernames matching the input From 9cbdb093bd29ee92f2403da4e2ff11af3cde893e Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sun, 29 Mar 2026 21:24:45 -0300 Subject: [PATCH 30/37] This commit introduces the `McpInvoker` interface to wrap all generated MCP tool, prompt, resource, and completion calls. It centralizes execution logic, exception handling, and protocol error mapping, while providing developers a clean hook to inject custom telemetry, tracing, or MDC context propagation. Details: * Added `McpInvoker` interface and `DefaultMcpInvoker` implementation. * Integrated Jooby's `Router.errorCode()` to seamlessly map standard framework exceptions (e.g., 400 Bad Request) to standard MCP JSON-RPC errors (e.g., -32602 Invalid Params). * Implemented LLM "self-healing" for tools: Unhandled business exceptions are now caught and returned as a `CallToolResult` with `isError=true`. This prevents protocol aborts and feeds the error text directly back into the LLM context so it can self-correct. * Updated the APT generator (`McpRouter`) to dynamically resolve the `McpInvoker` from the Jooby application registry using local variables, ensuring the generated router remains completely stateless. * Wrapped all routing lambdas in `invoker.invoke(operationId, action)`, passing contextual operation IDs (e.g., `tools/add_numbers` or `resources/calculator://history/{user}`). --- .../java/io/jooby/internal/apt/McpRouter.java | 142 +++++++++++++----- .../src/test/java/tests/i3830/Issue3830.java | 32 ++-- .../jooby/internal/mcp/DefaultMcpInvoker.java | 52 +++++++ .../main/java/io/jooby/mcp/McpHandler.java | 15 -- .../main/java/io/jooby/mcp/McpInvoker.java | 29 ++++ .../src/main/java/io/jooby/mcp/McpModule.java | 26 +++- .../main/java/io/jooby/mcp/McpService.java | 5 +- .../io/jooby/i3830/CalculatorToolsTest.java | 71 +++++++++ 8 files changed, 298 insertions(+), 74 deletions(-) create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/DefaultMcpInvoker.java delete mode 100644 modules/jooby-mcp/src/main/java/io/jooby/mcp/McpHandler.java create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInvoker.java diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java index 38aa277462..5357edfe0c 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java @@ -240,9 +240,11 @@ public String toSourceCode(boolean kt) throws IOException { buffer.append( statement( indent(4), - "override fun completions():" + "override fun completions(app: io.jooby.Jooby):" + " List" + " {")); + buffer.append( + statement(indent(6), "val invoker = app.require(io.jooby.mcp.McpInvoker::class.java)")); buffer.append( statement( indent(6), @@ -255,7 +257,12 @@ public String toSourceCode(boolean kt) throws IOException { indent(4), "public" + " java.util.List" - + " completions() {")); + + " completions(io.jooby.Jooby app) {")); + buffer.append( + statement( + indent(6), + "var invoker = app.require(io.jooby.mcp.McpInvoker.class)", + semicolon(kt))); buffer.append( statement( indent(6), @@ -264,7 +271,6 @@ public String toSourceCode(boolean kt) throws IOException { semicolon(kt))); } - // Loop over ALL possible refs, not just the ones with explicit handlers for (var ref : allCompletionRefs) { var isResource = ref.contains("://"); var refObj = @@ -275,17 +281,20 @@ public String toSourceCode(boolean kt) throws IOException { String lambda; if (completionGroups.containsKey(ref)) { var handlerName = findTargetMethodName(ref) + "CompletionHandler"; + var operationId = "completions/" + ref; lambda = kt - ? "{ exchange, req -> this." + ? "{ exchange, req -> invoker.invoke(" + + string(operationId) + + ") { this." + handlerName - + "(exchange, exchange.transportContext(), req) }" - : "(exchange, req) -> this." + + "(exchange, exchange.transportContext(), req) } }" + : "(exchange, req) -> invoker.invoke(" + + string(operationId) + + ", () -> this." + handlerName - + "(exchange, exchange.transportContext(), req)"; - + + "(exchange, exchange.transportContext(), req))"; } else { - // Fallback: Return an empty completion result safely lambda = kt ? "{ _, _ -> io.jooby.mcp.McpResult(this.json).toCompleteResult(emptyList()) }" @@ -328,9 +337,11 @@ public String toSourceCode(boolean kt) throws IOException { buffer.append( statement( indent(4), - "override fun statelessCompletions():" + "override fun statelessCompletions(app: io.jooby.Jooby):" + " List" + " {")); + buffer.append( + statement(indent(6), "val invoker = app.require(io.jooby.mcp.McpInvoker::class.java)")); buffer.append( statement( indent(6), @@ -343,7 +354,12 @@ public String toSourceCode(boolean kt) throws IOException { indent(4), "public" + " java.util.List" - + " statelessCompletions() {")); + + " statelessCompletions(io.jooby.Jooby app) {")); + buffer.append( + statement( + indent(6), + "var invoker = app.require(io.jooby.mcp.McpInvoker.class)", + semicolon(kt))); buffer.append( statement( indent(6), @@ -352,7 +368,6 @@ public String toSourceCode(boolean kt) throws IOException { semicolon(kt))); } - // Loop over ALL possible refs for (var ref : allCompletionRefs) { var isResource = ref.contains("://"); var refObj = @@ -363,12 +378,20 @@ public String toSourceCode(boolean kt) throws IOException { String lambda; if (completionGroups.containsKey(ref)) { var handlerName = findTargetMethodName(ref) + "CompletionHandler"; + var operationId = "completions/" + ref; lambda = kt - ? "{ ctx, req -> this." + handlerName + "(null, ctx, req) }" - : "(ctx, req) -> this." + handlerName + "(null, ctx, req)"; + ? "{ ctx, req -> invoker.invoke(" + + string(operationId) + + ") { this." + + handlerName + + "(null, ctx, req) } }" + : "(ctx, req) -> invoker.invoke(" + + string(operationId) + + ", () -> this." + + handlerName + + "(null, ctx, req))"; } else { - // Fallback: Return an empty completion result safely lambda = kt ? "{ _, _ -> io.jooby.mcp.McpResult(this.json).toCompleteResult(emptyList()) }" @@ -424,6 +447,8 @@ public String toSourceCode(boolean kt) throws IOException { indent(6), "this.json =" + " app.require(io.modelcontextprotocol.json.McpJsonMapper::class.java)")); + buffer.append( + statement(indent(6), "val invoker = app.require(io.jooby.mcp.McpInvoker::class.java)")); if (!tools.isEmpty()) { buffer.append( @@ -445,6 +470,11 @@ public String toSourceCode(boolean kt) throws IOException { indent(6), "this.json =" + " app.require(io.modelcontextprotocol.json.McpJsonMapper.class)", semicolon(kt))); + buffer.append( + statement( + indent(6), + "var invoker = app.require(io.jooby.mcp.McpInvoker.class)", + semicolon(kt))); if (!tools.isEmpty()) { buffer.append( @@ -464,22 +494,72 @@ public String toSourceCode(boolean kt) throws IOException { for (var route : getRoutes()) { var methodName = route.getMethodName(); + String mcpType = ""; + String mcpName = ""; + if (route.isMcpTool()) { + mcpType = "tools"; + var ann = + AnnotationSupport.findAnnotationByName( + route.getMethod(), "io.jooby.annotation.mcp.McpTool"); + mcpName = + ann != null + ? AnnotationSupport.findAnnotationValue(ann, "name"::equals).stream() + .findFirst() + .orElse("") + : ""; + } else if (route.isMcpPrompt()) { + mcpType = "prompts"; + var ann = + AnnotationSupport.findAnnotationByName( + route.getMethod(), "io.jooby.annotation.mcp.McpPrompt"); + mcpName = + ann != null + ? AnnotationSupport.findAnnotationValue(ann, "name"::equals).stream() + .findFirst() + .orElse("") + : ""; + } else if (route.isMcpResource() || route.isMcpResourceTemplate()) { + mcpType = "resources"; + var ann = + AnnotationSupport.findAnnotationByName( + route.getMethod(), "io.jooby.annotation.mcp.McpResource"); + mcpName = + ann != null + ? AnnotationSupport.findAnnotationValue(ann, "uri"::equals).stream() + .findFirst() + .orElse("") + : ""; + } + if (mcpName == null || mcpName.isEmpty()) mcpName = methodName; + String operationId = mcpType + "/" + mcpName; + // --- Lambda Router Definition --- String lambda = kt ? (isStateless - ? "{ ctx, req -> this." + methodName + "(null, ctx, req) }" - : "{ exchange, req -> this." + ? "{ ctx, req -> invoker.invoke(" + + string(operationId) + + ") { this." + + methodName + + "(null, ctx, req) } }" + : "{ exchange, req -> invoker.invoke(" + + string(operationId) + + ") { this." + methodName - + "(exchange, exchange.transportContext(), req) }") + + "(exchange, exchange.transportContext(), req) } }") : (isStateless - ? "(ctx, req) -> this." + methodName + "(null, ctx, req)" - : "(exchange, req) -> this." + ? "(ctx, req) -> invoker.invoke(" + + string(operationId) + + ", () -> this." + methodName - + "(exchange, exchange.transportContext(), req)"); + + "(null, ctx, req))" + : "(exchange, req) -> invoker.invoke(" + + string(operationId) + + ", () -> this." + + methodName + + "(exchange, exchange.transportContext(), req))"); if (route.isMcpTool()) { - // Removed "mapper" from defArgs var defArgs = "schemaGenerator"; if (kt) { buffer.append( @@ -593,19 +673,11 @@ public String toSourceCode(boolean kt) throws IOException { "private fun ", handlerName, "(exchange: io.modelcontextprotocol.server.McpSyncServerExchange?," - + " transportContext:" - + " io.modelcontextprotocol.common.McpTransportContext," - + " req:" // Removed '?' + + " transportContext: io.modelcontextprotocol.common.McpTransportContext, req:" + " io.modelcontextprotocol.spec.McpSchema.CompleteRequest):" + " io.modelcontextprotocol.spec.McpSchema.CompleteResult {")); - - // Direct extraction, no fallback needed buffer.append( - statement( - indent(6), - "val ctx =" - + " transportContext.get(io.jooby.Context::class.java.name)")); - + statement(indent(6), "val ctx = transportContext.get(\"CTX\") as io.jooby.Context")); buffer.append(statement(indent(6), "val c = this.factory.apply(ctx)")); buffer.append(statement(indent(6), "val targetArg = req.argument()?.name() ?: \"\"")); buffer.append(statement(indent(6), "val typedValue = req.argument()?.value() ?: \"\"")); @@ -617,17 +689,13 @@ public String toSourceCode(boolean kt) throws IOException { "private io.modelcontextprotocol.spec.McpSchema.CompleteResult ", handlerName, "(io.modelcontextprotocol.server.McpSyncServerExchange exchange," - + " io.modelcontextprotocol.common.McpTransportContext" - + " transportContext," // Guaranteed non-null + + " io.modelcontextprotocol.common.McpTransportContext transportContext," + " io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) {")); - - // Direct extraction, no ternary operator buffer.append( statement( indent(6), "var ctx = (io.jooby.Context) transportContext.get(\"CTX\")", semicolon(kt))); - buffer.append(statement(indent(6), "var c = this.factory.apply(ctx)", semicolon(kt))); buffer.append( statement( diff --git a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java index 379282fbf0..a7daba8c47 100644 --- a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java +++ b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java @@ -61,41 +61,45 @@ public String serverKey() { } @Override - public java.util.List completions() { + public java.util.List completions(io.jooby.Jooby app) { + var invoker = app.require(io.jooby.mcp.McpInvoker.class); var completions = new java.util.ArrayList(); - completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), (exchange, req) -> this.reviewCodeCompletionHandler(exchange, exchange.transportContext(), req))); - completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.ResourceReference("file:///users/{id}/{name}/profile"), (exchange, req) -> this.getUserProfileCompletionHandler(exchange, exchange.transportContext(), req))); + completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), (exchange, req) -> invoker.invoke("completions/review_code", () -> this.reviewCodeCompletionHandler(exchange, exchange.transportContext(), req)))); + completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.ResourceReference("file:///users/{id}/{name}/profile"), (exchange, req) -> invoker.invoke("completions/file:///users/{id}/{name}/profile", () -> this.getUserProfileCompletionHandler(exchange, exchange.transportContext(), req)))); return completions; } @Override - public java.util.List statelessCompletions() { + public java.util.List statelessCompletions(io.jooby.Jooby app) { + var invoker = app.require(io.jooby.mcp.McpInvoker.class); var completions = new java.util.ArrayList(); - completions.add(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), (ctx, req) -> this.reviewCodeCompletionHandler(null, ctx, req))); - completions.add(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.ResourceReference("file:///users/{id}/{name}/profile"), (ctx, req) -> this.getUserProfileCompletionHandler(null, ctx, req))); + completions.add(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), (ctx, req) -> invoker.invoke("completions/review_code", () -> this.reviewCodeCompletionHandler(null, ctx, req)))); + completions.add(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.ResourceReference("file:///users/{id}/{name}/profile"), (ctx, req) -> invoker.invoke("completions/file:///users/{id}/{name}/profile", () -> this.getUserProfileCompletionHandler(null, ctx, req)))); return completions; } @Override public void install(io.jooby.Jooby app, io.modelcontextprotocol.server.McpSyncServer server) throws Exception { this.json = app.require(io.modelcontextprotocol.json.McpJsonMapper.class); + var invoker = app.require(io.jooby.mcp.McpInvoker.class); var schemaGenerator = app.require(com.github.victools.jsonschema.generator.SchemaGenerator.class); - server.addTool(new io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification(addToolSpec(schemaGenerator), (exchange, req) -> this.add(exchange, exchange.transportContext(), req))); - server.addPrompt(new io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification(reviewCodePromptSpec(), (exchange, req) -> this.reviewCode(exchange, exchange.transportContext(), req))); - server.addResource(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification(getLogsResourceSpec(), (exchange, req) -> this.getLogs(exchange, exchange.transportContext(), req))); - server.addResourceTemplate(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceTemplateSpecification(getUserProfileResourceTemplateSpec(), (exchange, req) -> this.getUserProfile(exchange, exchange.transportContext(), req))); + server.addTool(new io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification(addToolSpec(schemaGenerator), (exchange, req) -> invoker.invoke("tools/calculator", () -> this.add(exchange, exchange.transportContext(), req)))); + server.addPrompt(new io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification(reviewCodePromptSpec(), (exchange, req) -> invoker.invoke("prompts/review_code", () -> this.reviewCode(exchange, exchange.transportContext(), req)))); + server.addResource(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification(getLogsResourceSpec(), (exchange, req) -> invoker.invoke("resources/file:///logs/app.log", () -> this.getLogs(exchange, exchange.transportContext(), req)))); + server.addResourceTemplate(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceTemplateSpecification(getUserProfileResourceTemplateSpec(), (exchange, req) -> invoker.invoke("resources/file:///users/{id}/{name}/profile", () -> this.getUserProfile(exchange, exchange.transportContext(), req)))); } @Override public void install(io.jooby.Jooby app, io.modelcontextprotocol.server.McpStatelessSyncServer server) throws Exception { this.json = app.require(io.modelcontextprotocol.json.McpJsonMapper.class); + var invoker = app.require(io.jooby.mcp.McpInvoker.class); var schemaGenerator = app.require(com.github.victools.jsonschema.generator.SchemaGenerator.class); - server.addTool(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncToolSpecification(addToolSpec(schemaGenerator), (ctx, req) -> this.add(null, ctx, req))); - server.addPrompt(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncPromptSpecification(reviewCodePromptSpec(), (ctx, req) -> this.reviewCode(null, ctx, req))); - server.addResource(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceSpecification(getLogsResourceSpec(), (ctx, req) -> this.getLogs(null, ctx, req))); - server.addResourceTemplate(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceTemplateSpecification(getUserProfileResourceTemplateSpec(), (ctx, req) -> this.getUserProfile(null, ctx, req))); + server.addTool(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncToolSpecification(addToolSpec(schemaGenerator), (ctx, req) -> invoker.invoke("tools/calculator", () -> this.add(null, ctx, req)))); + server.addPrompt(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncPromptSpecification(reviewCodePromptSpec(), (ctx, req) -> invoker.invoke("prompts/review_code", () -> this.reviewCode(null, ctx, req)))); + server.addResource(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceSpecification(getLogsResourceSpec(), (ctx, req) -> invoker.invoke("resources/file:///logs/app.log", () -> this.getLogs(null, ctx, req)))); + server.addResourceTemplate(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceTemplateSpecification(getUserProfileResourceTemplateSpec(), (ctx, req) -> invoker.invoke("resources/file:///users/{id}/{name}/profile", () -> this.getUserProfile(null, ctx, req)))); } private io.modelcontextprotocol.spec.McpSchema.Tool addToolSpec(com.github.victools.jsonschema.generator.SchemaGenerator schemaGenerator) { diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/DefaultMcpInvoker.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/DefaultMcpInvoker.java new file mode 100644 index 0000000000..18751bdeeb --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/DefaultMcpInvoker.java @@ -0,0 +1,52 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.mcp; + +import io.jooby.Jooby; +import io.jooby.SneakyThrows; +import io.jooby.StatusCode; +import io.jooby.mcp.McpInvoker; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema; + +public class DefaultMcpInvoker implements McpInvoker { + private final Jooby application; + + public DefaultMcpInvoker(Jooby application) { + this.application = application; + } + + @SuppressWarnings("unchecked") + @Override + public R invoke(String operationId, SneakyThrows.Supplier action) { + try { + return action.get(); + } catch (McpError mcpError) { + throw mcpError; + } catch (Throwable cause) { + if (operationId.startsWith("tools/")) { + // Tool error + var errorMessage = cause.getMessage() != null ? cause.getMessage() : cause.toString(); + return (R) + McpSchema.CallToolResult.builder().addTextContent(errorMessage).isError(true).build(); + } + var statusCode = application.getRouter().errorCode(cause); + var mcpErrorCode = toMcpErrorCode(statusCode); + throw new McpError( + new McpSchema.JSONRPCResponse.JSONRPCError(mcpErrorCode, cause.getMessage(), null)); + } + } + + private int toMcpErrorCode(StatusCode statusCode) { + return switch (statusCode.value()) { + case StatusCode.BAD_REQUEST_CODE, StatusCode.CONFLICT_CODE -> + McpSchema.ErrorCodes.INVALID_PARAMS; + case StatusCode.NOT_FOUND_CODE -> McpSchema.ErrorCodes.RESOURCE_NOT_FOUND; + + default -> McpSchema.ErrorCodes.INTERNAL_ERROR; + }; + } +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpHandler.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpHandler.java deleted file mode 100644 index 47771ce736..0000000000 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpHandler.java +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.mcp; - -import io.jooby.SneakyThrows; - -public class McpHandler { - - public static R invoke(SneakyThrows.Supplier action) { - return action.get(); - } -} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInvoker.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInvoker.java new file mode 100644 index 0000000000..47a9c295f6 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInvoker.java @@ -0,0 +1,29 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.mcp; + +import io.jooby.SneakyThrows; + +public interface McpInvoker { + + R invoke(String operationId, SneakyThrows.Supplier action); + + /** + * Chains this invoker with another one. This invoker runs first, and its "action" becomes calling + * the next invoker. + * + * @param next The next invoker in the chain. + * @return A composed invoker. + */ + default McpInvoker then(McpInvoker next) { + return new McpInvoker() { + @Override + public R invoke(String operationId, SneakyThrows.Supplier action) { + return McpInvoker.this.invoke(operationId, () -> next.invoke(operationId, action)); + } + }; + } +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java index 8ae444af08..e1a69c1f8f 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java @@ -17,6 +17,7 @@ import io.jooby.Jooby; import io.jooby.ServiceKey; import io.jooby.exception.StartupException; +import io.jooby.internal.mcp.DefaultMcpInvoker; import io.jooby.internal.mcp.McpServerConfig; import io.jooby.internal.mcp.transport.SseTransportProvider; import io.jooby.internal.mcp.transport.StatelessTransportProvider; @@ -124,6 +125,8 @@ public class McpModule implements Extension { private final List mcpServices = new ArrayList<>(); + private McpInvoker invoker; + public McpModule(McpService mcpService, McpService... mcpServices) { this.mcpServices.add(mcpService); if (mcpServices != null) { @@ -136,10 +139,21 @@ public McpModule transport(@NonNull Transport transport) { return this; } + public McpModule invoker(@NonNull McpInvoker invoker) { + this.invoker = invoker; + return this; + } + @Override public void install(@NonNull Jooby app) { var services = app.getServices(); var mcpJsonMapper = services.require(McpJsonMapper.class); + // invoker + McpInvoker invoker = new DefaultMcpInvoker(app); + if (this.invoker != null) { + invoker = invoker.then(this.invoker); + } + services.put(McpInvoker.class, invoker); // Group services by server var mcpServiceMap = new HashMap>(); for (var mcpService : mcpServices) { @@ -161,7 +175,7 @@ public void install(@NonNull Jooby app) { var statelessServer = McpServer.sync(transport) .serverInfo(mcpConfig.getName(), mcpConfig.getVersion()) - .completions(statelessCompletions(serverEntry)) + .completions(statelessCompletions(app, serverEntry)) .capabilities(capabilities.build()) .instructions(mcpConfig.getInstructions()) .build(); @@ -196,7 +210,7 @@ public void install(@NonNull Jooby app) { "Unsupported transport: " + mcpConfig.getTransport()); }) .serverInfo(mcpConfig.getName(), mcpConfig.getVersion()) - .completions(completions(serverEntry)) + .completions(completions(app, serverEntry)) .capabilities(capabilities.build()) .instructions(mcpConfig.getInstructions()) .build(); @@ -215,17 +229,17 @@ public void install(@NonNull Jooby app) { } private static List completions( - Map.Entry> serverEntry) { + Jooby application, Map.Entry> serverEntry) { return serverEntry.getValue().stream() - .map(McpService::completions) + .map(it -> it.completions(application)) .flatMap(List::stream) .toList(); } private static List statelessCompletions( - Map.Entry> serverEntry) { + Jooby application, Map.Entry> serverEntry) { return serverEntry.getValue().stream() - .map(McpService::statelessCompletions) + .map(it -> it.statelessCompletions(application)) .flatMap(List::stream) .toList(); } diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java index b60d71b1f5..ceab965a7a 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java @@ -23,9 +23,10 @@ public interface McpService { void capabilities(McpSchema.ServerCapabilities.Builder capabilities); - List completions(); + List completions(Jooby application); - List statelessCompletions(); + List statelessCompletions( + Jooby application); String serverKey(); } diff --git a/tests/src/test/java/io/jooby/i3830/CalculatorToolsTest.java b/tests/src/test/java/io/jooby/i3830/CalculatorToolsTest.java index 358c321ce2..1346840f3b 100644 --- a/tests/src/test/java/io/jooby/i3830/CalculatorToolsTest.java +++ b/tests/src/test/java/io/jooby/i3830/CalculatorToolsTest.java @@ -396,6 +396,77 @@ public void shouldGetMathTutorPrompt(ServerTestRunner runner) { }); } + @ServerTest + public void shouldGetUnknownTool(ServerTestRunner runner) { + runner + .define(app -> setupMcpApp(app, McpModule.Transport.STATELESS_STREAMABLE_HTTP)) + .ready( + client -> { + String jsonRpcRequest = + """ + { + "jsonrpc": "2.0", + "id": "req-tool-1", + "method": "tools/call", + "params": { + "name": "some_tool", + "arguments": { "topic": "Algebra" } + } + } + """; + + client.header("Content-Type", "application/json"); + client.postJson( + "/mcp", + jsonRpcRequest, + response -> { + assertEquals(200, response.code()); + String body = response.body().string(); + assertThat(body) + .isEqualToNormalizingWhitespace( + """ + {"jsonrpc":"2.0","id":"req-tool-1","error":{"code":-32602,"message":"Unknown tool: invalid_tool_name","data":"Tool not found: some_tool"}} + """); + }); + }); + } + + @ServerTest + public void shouldGetInvalidParams(ServerTestRunner runner) { + runner + .define(app -> setupMcpApp(app, McpModule.Transport.STATELESS_STREAMABLE_HTTP)) + .ready( + client -> { + String jsonRpcRequest = + """ + { + "jsonrpc": "2.0", + "id": "req-tool-1", + "method": "tools/call", + "params": { + "name": "add_numbers", + "arguments": { "a": 5, "b": "10" } + } + } + """; + + client.header("Content-Type", "application/json"); + client.postJson( + "/mcp", + jsonRpcRequest, + response -> { + assertEquals(200, response.code()); + String body = response.body().string(); + assertThat(body) + .containsIgnoringWhitespaces( + """ + "result": + """) + .containsIgnoringWhitespaces("\"isError\":true"); + }); + }); + } + @ServerTest public void shouldReadStaticResource(ServerTestRunner runner) { runner From f5ff5a1c6b37d974efabbbadcfe96f5c84087027 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 30 Mar 2026 12:07:35 -0300 Subject: [PATCH 31/37] - code cleanup for log/exception handling - print server started --- .../AbstractMcpTransportProvider.java | 3 +- .../mcp/transport/SseTransportProvider.java | 25 +++--- .../transport/StatelessTransportProvider.java | 27 +++--- .../StreamableTransportProvider.java | 84 ++++++++----------- .../transport/WebSocketTransportProvider.java | 16 ++-- .../java/io/jooby/mcp/McpInspectorModule.java | 2 +- .../src/main/java/io/jooby/mcp/McpModule.java | 45 ++++++++-- 7 files changed, 106 insertions(+), 96 deletions(-) diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/AbstractMcpTransportProvider.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/AbstractMcpTransportProvider.java index f22bb93d58..4b441ed9f8 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/AbstractMcpTransportProvider.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/AbstractMcpTransportProvider.java @@ -22,7 +22,6 @@ public abstract class AbstractMcpTransportProvider implements McpServerTransportProvider { protected final Logger log = LoggerFactory.getLogger(getClass()); - protected final McpJsonMapper mcpJsonMapper; protected final McpTransportContextExtractor contextExtractor; protected final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); @@ -62,7 +61,7 @@ public Mono notifyClients(String method, Object params) { .doOnError( e -> log.error( - "Failed to send message to {} session {}: {}", + "Failed to send a message to {} session {}: {}", transportName(), session.getId(), e.getMessage())) diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/SseTransportProvider.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/SseTransportProvider.java index 9af8569ae8..2a5ccdd70a 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/SseTransportProvider.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/SseTransportProvider.java @@ -30,7 +30,7 @@ public SseTransportProvider( McpTransportContextExtractor contextExtractor) { super(mcpJsonMapper, contextExtractor); this.messageEndpoint = serverConfig.getMessageEndpoint(); - String sseEndpoint = serverConfig.getSseEndpoint(); + var sseEndpoint = serverConfig.getSseEndpoint(); app.head(sseEndpoint, ctx -> StatusCode.OK).produces(TEXT_EVENT_STREAM); app.sse(sseEndpoint, this::handleSseConnection); @@ -43,9 +43,9 @@ protected String transportName() { } private void handleSseConnection(ServerSentEmitter sse) { - JoobyMcpSessionTransport transport = new JoobyMcpSessionTransport(mcpJsonMapper, sse); - McpServerSession session = sessionFactory.create(transport); - String sessionId = session.getId(); + var transport = new JoobyMcpSessionTransport(mcpJsonMapper, sse); + var session = sessionFactory.create(transport); + var sessionId = session.getId(); log.debug("New SSE connection established. Session ID: {}", sessionId); sessions.put(sessionId, session); @@ -76,8 +76,8 @@ private Object handleMessage(Context ctx) { .build(); } - String sessionId = ctx.query(SESSION_ID_KEY).value(); - McpServerSession session = sessions.get(sessionId); + var sessionId = ctx.query(SESSION_ID_KEY).value(); + var session = sessions.get(sessionId); if (session == null) { ctx.setResponseCode(StatusCode.NOT_FOUND); @@ -87,10 +87,9 @@ private Object handleMessage(Context ctx) { } try { - McpTransportContext transportContext = this.contextExtractor.extract(ctx); + var transportContext = this.contextExtractor.extract(ctx); var body = ctx.body().value(); - McpSchema.JSONRPCMessage message = - McpSchema.deserializeJsonRpcMessage(this.mcpJsonMapper, body); + var message = McpSchema.deserializeJsonRpcMessage(this.mcpJsonMapper, body); return session .handle(message) @@ -98,13 +97,13 @@ private Object handleMessage(Context ctx) { .then(Mono.just((Object) StatusCode.OK)) .onErrorResume( error -> { - log.error("Error processing message: {}", error.getMessage()); + log.error("Error processing message", error); return Mono.just(StatusCode.OK); }) .switchIfEmpty(Mono.just((Object) StatusCode.OK)) .block(); } catch (IOException | IllegalArgumentException e) { - log.error("Failed to deserialize message: {}", e.getMessage()); + log.error("Failed to deserialize a message", e); return McpError.builder(McpSchema.ErrorCodes.PARSE_ERROR) .message("Invalid message format") .build(); @@ -124,10 +123,10 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message) { return Mono.fromRunnable( () -> { try { - String jsonText = mcpJsonMapper.writeValueAsString(message); + var jsonText = mcpJsonMapper.writeValueAsString(message); sse.send(new ServerSentMessage(jsonText).setEvent(MESSAGE_EVENT_TYPE)); } catch (Exception e) { - log.error("Failed to send message: {}", e.getMessage()); + log.error("Failed to send a message", e); sse.send(SSE_ERROR_EVENT, e.getMessage()); } }); diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/StatelessTransportProvider.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/StatelessTransportProvider.java index e06cf009fe..c3eb4df42b 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/StatelessTransportProvider.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/StatelessTransportProvider.java @@ -35,9 +35,7 @@ */ @SuppressWarnings("PMD") public class StatelessTransportProvider implements McpStatelessServerTransport { - - private static final Logger LOG = LoggerFactory.getLogger(StatelessTransportProvider.class); - + private final Logger log = LoggerFactory.getLogger(getClass()); private McpStatelessServerHandler mcpHandler; private final McpJsonMapper mcpJsonMapper; private final McpTransportContextExtractor contextExtractor; @@ -66,26 +64,23 @@ private Object handlePost(Context ctx) { return SendError.invalidAcceptHeader(ctx, List.of(TEXT_EVENT_STREAM, MediaType.json)); } - McpTransportContext transportContext = this.contextExtractor.extract(ctx); + var transportContext = this.contextExtractor.extract(ctx); try { var body = ctx.body().valueOrNull(); if (body == null) { return SendError.error( ctx, StatusCode.BAD_REQUEST, INVALID_REQUEST, "Request body is missing"); } - McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(mcpJsonMapper, body); + var message = McpSchema.deserializeJsonRpcMessage(mcpJsonMapper, body); if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { try { - McpSchema.JSONRPCResponse jsonrpcResponse = - this.mcpHandler - .handleRequest(transportContext, jsonrpcRequest) - .contextWrite( - reactorCtx -> reactorCtx.put(McpTransportContext.KEY, transportContext)) - .block(); - return jsonrpcResponse; + return this.mcpHandler + .handleRequest(transportContext, jsonrpcRequest) + .contextWrite(reactorCtx -> reactorCtx.put(McpTransportContext.KEY, transportContext)) + .block(); } catch (Exception e) { - LOG.error("Failed to handle request.", e); + log.error("Failed to handle request", e); return SendError.internalError(ctx); } } else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { @@ -96,17 +91,17 @@ private Object handlePost(Context ctx) { .block(); return StatusCode.ACCEPTED; } catch (Exception e) { - LOG.error("Failed to handle notification", e); + log.error("Failed to handle notification", e); return SendError.internalError(ctx); } } else { return SendError.badRequest(ctx, "The server accepts either requests or notifications"); } } catch (IllegalArgumentException | IOException e) { - LOG.error("Failed to deserialize message.", e); + log.error("Failed to deserialize a message", e); return SendError.badRequest(ctx, "Invalid message format"); } catch (Exception e) { - LOG.error("Unexpected error handling message.", e); + log.error("Unexpected error handling message", e); return SendError.internalError(ctx); } } diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/StreamableTransportProvider.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/StreamableTransportProvider.java index 478560c17a..f7c6351750 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/StreamableTransportProvider.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/StreamableTransportProvider.java @@ -13,6 +13,7 @@ import java.util.List; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -31,15 +32,12 @@ /** Jooby implementation of Streamable HTTP transport. */ @SuppressWarnings("PMD") public class StreamableTransportProvider implements McpStreamableServerTransportProvider { - - private static final Logger LOG = LoggerFactory.getLogger(StreamableTransportProvider.class); - + private final Logger log = LoggerFactory.getLogger(getClass()); private final boolean disallowDelete; private final McpJsonMapper mcpJsonMapper; - private final ConcurrentHashMap sessions = + private final ConcurrentMap sessions = new ConcurrentHashMap<>(); private final McpTransportContextExtractor contextExtractor; - private volatile boolean isClosing = false; private McpStreamableServerSession.Factory sessionFactory; private KeepAliveScheduler keepAliveScheduler; @@ -80,23 +78,23 @@ private Context handleGet(Context ctx) { return SendError.invalidAcceptHeader(ctx, List.of(TEXT_EVENT_STREAM)); if (ctx.header(HttpHeaders.MCP_SESSION_ID).isMissing()) return SendError.missingSessionId(ctx); - String sessionId = ctx.header(HttpHeaders.MCP_SESSION_ID).value(); - McpStreamableServerSession session = this.sessions.get(sessionId); + var sessionId = ctx.header(HttpHeaders.MCP_SESSION_ID).value(); + var session = this.sessions.get(sessionId); if (session == null) return SendError.sessionNotFound(ctx, sessionId); - McpTransportContext transportContext = this.contextExtractor.extract(ctx); - LOG.debug("Handling GET request for session: {}", sessionId); + var transportContext = this.contextExtractor.extract(ctx); + log.debug("Handling GET request for session: {}", sessionId); try { ctx.setResponseType(TEXT_EVENT_STREAM); return ctx.upgrade( sse -> { sse.onClose( - () -> LOG.debug("SSE connection closed by client for session: {}", sessionId)); + () -> log.debug("SSE connection closed by client for session: {}", sessionId)); var sessionTransport = new StreamableMcpSessionTransport(sessionId, sse); if (ctx.header(HttpHeaders.LAST_EVENT_ID).isPresent()) { - String lastId = ctx.header(HttpHeaders.LAST_EVENT_ID).value(); + var lastId = ctx.header(HttpHeaders.LAST_EVENT_ID).value(); // FIX: Replaced blocking .forEach with non-blocking .concatMap session @@ -113,21 +111,20 @@ private Context handleGet(Context ctx) { .subscribe( null, error -> { - LOG.error("Failed to replay messages: {}", error.getMessage()); + log.error("Failed to replay messages", error); sse.send(SSE_ERROR_EVENT, error.getMessage()); }); } else { - McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = - session.listeningStream(sessionTransport); + var listeningStream = session.listeningStream(sessionTransport); sse.onClose( () -> { - LOG.debug("SSE connection has been closed for session: {}", sessionId); + log.debug("SSE connection has been closed for session: {}", sessionId); listeningStream.close(); }); } }); } catch (Exception e) { - LOG.error("Failed to handle GET request for session {}: {}", sessionId, e.getMessage()); + log.error("Failed to handle GET request for session {}", sessionId, e); return SendError.internalError(ctx, sessionId); } } @@ -138,7 +135,7 @@ private Object handlePost(Context ctx) { return SendError.invalidAcceptHeader(ctx, List.of(TEXT_EVENT_STREAM, MediaType.json)); } - McpTransportContext transportContext = this.contextExtractor.extract(ctx); + var transportContext = this.contextExtractor.extract(ctx); String sessionId = null; try { @@ -147,24 +144,23 @@ private Object handlePost(Context ctx) { return SendError.error( ctx, StatusCode.BAD_REQUEST, INVALID_REQUEST, "Request body is missing"); - McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(mcpJsonMapper, body); + var message = McpSchema.deserializeJsonRpcMessage(mcpJsonMapper, body); if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest && McpSchema.METHOD_INITIALIZE.equals(jsonrpcRequest.method())) { - McpSchema.InitializeRequest initRequest = + var initRequest = mcpJsonMapper.convertValue(jsonrpcRequest.params(), McpSchema.InitializeRequest.class); - McpStreamableServerSession.McpStreamableServerSessionInit initObj = - this.sessionFactory.startSession(initRequest); + var initObj = this.sessionFactory.startSession(initRequest); sessionId = initObj.session().getId(); this.sessions.put(sessionId, initObj.session()); try { - McpSchema.InitializeResult initResult = initObj.initResult().block(); + var initResult = initObj.initResult().block(); ctx.setResponseHeader(HttpHeaders.MCP_SESSION_ID, sessionId); return new McpSchema.JSONRPCResponse( McpSchema.JSONRPC_VERSION, jsonrpcRequest.id(), initResult, null); } catch (Exception e) { - LOG.error("Failed to initialize session: {}", e.getMessage()); + log.error("Failed to initialize session", e); return SendError.internalError(ctx, sessionId); } } @@ -172,7 +168,7 @@ private Object handlePost(Context ctx) { if (ctx.header(HttpHeaders.MCP_SESSION_ID).isMissing()) return SendError.missingSessionId(ctx); sessionId = ctx.header(HttpHeaders.MCP_SESSION_ID).value(); - McpStreamableServerSession session = this.sessions.get(sessionId); + var session = this.sessions.get(sessionId); if (session == null) return SendError.sessionNotFound(ctx, sessionId); if (message instanceof McpSchema.JSONRPCResponse jsonrpcResponse) { @@ -195,12 +191,10 @@ private Object handlePost(Context ctx) { sse -> { sse.onClose( () -> - LOG.debug( + log.debug( "Request response stream completed for session: {}", finalSessionId)); - StreamableMcpSessionTransport sessionTransport = - new StreamableMcpSessionTransport(finalSessionId, sse); + var sessionTransport = new StreamableMcpSessionTransport(finalSessionId, sse); - // FIX: Replaced .block() with non-blocking .subscribe() to prevent I/O deadlock session .responseStream(jsonrpcRequest, sessionTransport) .contextWrite( @@ -208,7 +202,7 @@ private Object handlePost(Context ctx) { .subscribe( null, error -> { - LOG.error("Failed to handle request stream: {}", error.getMessage()); + log.error("Failed to handle request stream", error); sse.send(SSE_ERROR_EVENT, error.getMessage()); sse.close(); }); @@ -217,10 +211,10 @@ private Object handlePost(Context ctx) { return SendError.unknownMsgType(ctx, sessionId); } } catch (IllegalArgumentException | IOException e) { - LOG.error("Failed to deserialize message: {}", e.getMessage()); + log.error("Failed to deserialize message", e); return SendError.msgParseError(ctx, sessionId); } catch (Exception e) { - LOG.error("Unexpected error occurred while handling message: {}", e.getMessage()); + log.error("Unexpected error occurred while handling message", e); return SendError.internalError(ctx, sessionId); } } @@ -230,12 +224,12 @@ private Object handleDelete(Context ctx) { if (this.disallowDelete) return SendError.deletionNotAllowed(ctx); if (ctx.header(HttpHeaders.MCP_SESSION_ID).isMissing()) return SendError.missingSessionId(ctx); - String sessionId = ctx.header(HttpHeaders.MCP_SESSION_ID).value(); - McpStreamableServerSession session = this.sessions.get(sessionId); + var sessionId = ctx.header(HttpHeaders.MCP_SESSION_ID).value(); + var session = this.sessions.get(sessionId); if (session == null) return SendError.sessionNotFound(ctx, sessionId); try { - McpTransportContext transportContext = this.contextExtractor.extract(ctx); + var transportContext = this.contextExtractor.extract(ctx); session .delete() .contextWrite(reactorCtx -> reactorCtx.put(McpTransportContext.KEY, transportContext)) @@ -243,7 +237,7 @@ private Object handleDelete(Context ctx) { this.sessions.remove(sessionId); return StatusCode.NO_CONTENT; } catch (Exception e) { - LOG.error("Failed to delete session {}: {}", sessionId, e.getMessage()); + log.error("Failed to delete session {}", sessionId, e); return SendError.internalError(ctx, sessionId); } } @@ -257,25 +251,19 @@ public void setSessionFactory(McpStreamableServerSession.Factory sessionFactory) public Mono notifyClients(String method, Object params) { if (this.sessions.isEmpty()) return Mono.empty(); - // FIX: Replaced blocking Streams with Reactor Flux return Flux.fromIterable(this.sessions.values()) .flatMap( session -> session .sendNotification(method, params) .doOnError( - e -> - LOG.error( - "Failed to send message to session {}: {}", - session.getId(), - e.getMessage())) + e -> log.error("Failed to send message to session {}", session.getId(), e)) .onErrorComplete()) .then(); } @Override public Mono closeGracefully() { - // FIX: Replaced blocking Streams with Reactor Flux return Flux.fromIterable(sessions.values()) .doFirst(() -> this.isClosing = true) .flatMap(McpStreamableServerSession::closeGracefully) @@ -288,7 +276,6 @@ public Mono closeGracefully() { } private class StreamableMcpSessionTransport implements McpStreamableServerTransport { - private final String sessionId; private final ServerSentEmitter sse; private volatile boolean closed = false; @@ -309,21 +296,18 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId () -> { try { if (!closed) { - String jsonText = mcpJsonMapper.writeValueAsString(message); + var jsonText = mcpJsonMapper.writeValueAsString(message); sse.send( new ServerSentMessage(jsonText) .setId(messageId != null ? messageId : this.sessionId) .setEvent(MESSAGE_EVENT_TYPE)); } } catch (Exception e) { - LOG.error("Failed to send message to session {}: {}", this.sessionId, e.getMessage()); + log.error("Failed to send message to session {}", this.sessionId, e); try { sse.send(SSE_ERROR_EVENT, e.getMessage()); } catch (Exception errorEx) { - LOG.error( - "Failed to send error to SSE session {}: {}", - this.sessionId, - errorEx.getMessage()); + log.error("Failed to send error to SSE session {}", this.sessionId, errorEx); } } }); @@ -349,7 +333,7 @@ public void close() { sse.close(); } } catch (Exception e) { - LOG.warn("Failed to close SSE session {}: {}", sessionId, e.getMessage()); + log.debug("Failed to close SSE session {}", sessionId, e); } } } diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/WebSocketTransportProvider.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/WebSocketTransportProvider.java index 49992883ba..f4fa3165b2 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/WebSocketTransportProvider.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/WebSocketTransportProvider.java @@ -17,7 +17,6 @@ import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.server.McpTransportContextExtractor; import io.modelcontextprotocol.spec.McpSchema; -import io.modelcontextprotocol.spec.McpServerSession; import reactor.core.publisher.Mono; @SuppressWarnings("PMD") @@ -54,9 +53,9 @@ private void handleConnect(WebSocket ws) { return; } - JoobyMcpWebSocketTransport transport = new JoobyMcpWebSocketTransport(mcpJsonMapper, ws); - McpServerSession session = sessionFactory.create(transport); - String sessionId = session.getId(); + var transport = new JoobyMcpWebSocketTransport(mcpJsonMapper, ws); + var session = sessionFactory.create(transport); + var sessionId = session.getId(); ws.attribute(MCP_SESSION_ATTRIBUTE, sessionId); sessions.put(sessionId, session); @@ -71,10 +70,9 @@ private void handleMessage(WebSocket ws, WebSocketMessage msg) { } try { - Context ctx = ws.getContext(); - McpTransportContext transportContext = this.contextExtractor.extract(ctx); - McpSchema.JSONRPCMessage message = - McpSchema.deserializeJsonRpcMessage(this.mcpJsonMapper, msg.value()); + var ctx = ws.getContext(); + var transportContext = this.contextExtractor.extract(ctx); + var message = McpSchema.deserializeJsonRpcMessage(this.mcpJsonMapper, msg.value()); sessions .get(sessionId) @@ -121,7 +119,7 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message) { try { if (!closed) ws.send(mcpJsonMapper.writeValueAsString(message)); } catch (Exception e) { - log.error("Failed to send WebSocket message: {}", e.getMessage()); + log.error("Failed to send WebSocket message", e); } }); } diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInspectorModule.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInspectorModule.java index 6f8ea30e9b..857e39a070 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInspectorModule.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInspectorModule.java @@ -56,7 +56,7 @@ * * * @author kliushnichenko - * @since 1.9.0 + * @since 4.2.0 */ public class McpInspectorModule implements Extension { diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java index e1a69c1f8f..f2ce43a0ef 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java @@ -11,6 +11,9 @@ import java.util.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import edu.umd.cs.findbugs.annotations.NonNull; import io.jooby.Context; import io.jooby.Extension; @@ -120,6 +123,7 @@ public class McpModule implements Extension { }; private static final String MODULE_CONFIG_PREFIX = "mcp"; + private static final Logger log = LoggerFactory.getLogger(McpModule.class); private Transport defaultTransport = STREAMABLE_HTTP; @@ -166,9 +170,11 @@ public void install(@NonNull Jooby app) { // Internal usage only, required by mcp-inspector services.listOf(McpServerConfig.class).add(mcpConfig); - var capabilities = new McpSchema.ServerCapabilities.Builder(); - serverEntry.getValue().forEach(it -> it.capabilities(capabilities)); + var capabilitiesBuilder = new McpSchema.ServerCapabilities.Builder(); + serverEntry.getValue().forEach(it -> it.capabilities(capabilitiesBuilder)); + var capabilities = capabilitiesBuilder.build(); + boolean stateless; if (mcpConfig.getTransport() == STATELESS_STREAMABLE_HTTP) { var transport = new StatelessTransportProvider(app, mcpJsonMapper, mcpConfig, CTX_EXTRACTOR); @@ -176,7 +182,7 @@ public void install(@NonNull Jooby app) { McpServer.sync(transport) .serverInfo(mcpConfig.getName(), mcpConfig.getVersion()) .completions(statelessCompletions(app, serverEntry)) - .capabilities(capabilities.build()) + .capabilities(capabilities) .instructions(mcpConfig.getInstructions()) .build(); // install services @@ -189,6 +195,8 @@ public void install(@NonNull Jooby app) { ServiceKey.key(McpStatelessSyncServer.class, serverEntry.getKey()), statelessServer); services.listOf(McpStatelessSyncServer.class).add(statelessServer); + stateless = true; + app.onStop(statelessServer::close); } else { // Stupid MCP types, but it's the only way to make it work. @@ -211,7 +219,7 @@ public void install(@NonNull Jooby app) { }) .serverInfo(mcpConfig.getName(), mcpConfig.getVersion()) .completions(completions(app, serverEntry)) - .capabilities(capabilities.build()) + .capabilities(capabilities) .instructions(mcpConfig.getInstructions()) .build(); // install service @@ -222,9 +230,36 @@ public void install(@NonNull Jooby app) { services.putIfAbsent(McpSyncServer.class, syncServer); services.put(ServiceKey.key(McpSyncServer.class, serverEntry.getKey()), syncServer); services.listOf(McpSyncServer.class).add(syncServer); - + stateless = false; app.onStop(syncServer::close); } + // Startup message: + app.onStarting( + () -> { + var features = new ArrayList(); + if (capabilities.tools() != null) features.add("Tools"); + if (capabilities.prompts() != null) features.add("Prompts"); + if (capabilities.resources() != null) features.add("Resources"); + var featuresStr = features.isEmpty() ? "None" : String.join(" | ", features); + + log.info( + "MCP Server [{}({})] v{} online:", + mcpConfig.getName(), + serverEntry.getKey(), + mcpConfig.getVersion()); + log.info(" ├─ Transport : {}", mcpConfig.getTransport().getValue()); + if (!stateless) { + log.info( + " ├─ Keep-Alive : {}", + mcpConfig.getKeepAliveInterval() == null + ? "N/A" + : mcpConfig.getKeepAliveInterval() + " s"); + log.info( + " ├─ Session Rule : {}", + mcpConfig.isDisallowDelete() ? "Disallow Deletion (Strict)" : "Allow Deletion"); + } + log.info(" ╰─ Capabilities : {}", featuresStr); + }); } } From f628c707cb3aee63b7f2b62fd4edc6dab50f531c Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 30 Mar 2026 13:49:12 -0300 Subject: [PATCH 32/37] - mcp inspector finish user defined `/path` --- .../java/io/jooby/mcp/McpInspectorModule.java | 26 ++++++++----------- .../assets/initScript-B8iPFz0O.js | 5 ++-- .../resources/mcpInspector/assets/mcp.svg | 12 +++++++++ 3 files changed, 26 insertions(+), 17 deletions(-) create mode 100644 modules/jooby-mcp/src/main/resources/mcpInspector/assets/mcp.svg diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInspectorModule.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInspectorModule.java index 857e39a070..71a5a794ae 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInspectorModule.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInspectorModule.java @@ -69,23 +69,24 @@ public class McpInspectorModule implements Extension { - + MCP Inspector - - - + + +
+ - %s + %3$s """; private static final String AUTO_CONNECT_SCRIPT = """ - \ + \ """; private static final String DEFAULT_ENDPOINT = "/mcp-inspector"; @@ -117,12 +118,12 @@ public void install(@NonNull Jooby app) { this.indexHtml = buildIndexHtml(); this.mcpSrvConfig = resolveMcpServerConfig(app); - app.assets("/mcp-inspector/static/*", "/mcpInspector/assets/"); + app.assets(inspectorEndpoint + "/static/*", "/mcpInspector/assets/"); app.get(inspectorEndpoint, ctx -> ctx.setResponseType(MediaType.html).render(this.indexHtml)); app.get( - "/mcp-inspector/config", + inspectorEndpoint + "/config", ctx -> { var location = resolveLocation(ctx); var configJson = buildConfigJson(mcpSrvConfig, location); @@ -131,8 +132,8 @@ public void install(@NonNull Jooby app) { } private String buildIndexHtml() { - var script = this.autoConnect ? AUTO_CONNECT_SCRIPT : ""; - return INDEX_HTML_TEMPLATE.formatted(DIST, DIST, DIST, script); + var script = this.autoConnect ? AUTO_CONNECT_SCRIPT.formatted(inspectorEndpoint) : ""; + return INDEX_HTML_TEMPLATE.formatted(DIST, inspectorEndpoint, script); } private String resolveLocation(Context ctx) { @@ -196,9 +197,4 @@ private String resolveEndpoint(McpServerConfig config) { return config.getMcpEndpoint(); } } - - @Override - public boolean lateinit() { - return true; - } } diff --git a/modules/jooby-mcp/src/main/resources/mcpInspector/assets/initScript-B8iPFz0O.js b/modules/jooby-mcp/src/main/resources/mcpInspector/assets/initScript-B8iPFz0O.js index cd02c54354..57e8311d97 100644 --- a/modules/jooby-mcp/src/main/resources/mcpInspector/assets/initScript-B8iPFz0O.js +++ b/modules/jooby-mcp/src/main/resources/mcpInspector/assets/initScript-B8iPFz0O.js @@ -68,8 +68,9 @@ function patchMcpProxyAddress(storageKey, newAddress) { function patch_mcp_inspector_config() { localStorage.setItem("lastConnectionType", "direct"); - const url = location.origin + "/mcp-inspector"; + const cp = document.getElementById('contextPath').value; + const url = location.origin + cp; patchMcpProxyAddress(INSPECTOR_CONFIG_KEY, url); } -patch_mcp_inspector_config(); \ No newline at end of file +patch_mcp_inspector_config(); diff --git a/modules/jooby-mcp/src/main/resources/mcpInspector/assets/mcp.svg b/modules/jooby-mcp/src/main/resources/mcpInspector/assets/mcp.svg new file mode 100644 index 0000000000..03d9f85d32 --- /dev/null +++ b/modules/jooby-mcp/src/main/resources/mcpInspector/assets/mcp.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + From 8d53bbffaf19fd8b4be07a7515398806301c40e0 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 30 Mar 2026 14:22:51 -0300 Subject: [PATCH 33/37] - add javadoc to public classes --- .../main/java/io/jooby/mcp/McpInvoker.java | 55 ++++++++ .../src/main/java/io/jooby/mcp/McpModule.java | 117 +++++++++++------- .../src/main/java/io/jooby/mcp/McpResult.java | 60 +++++++++ .../main/java/io/jooby/mcp/McpService.java | 61 ++++++++- .../main/java/io/jooby/mcp/package-info.java | 109 ++++++++++++++++ 5 files changed, 354 insertions(+), 48 deletions(-) create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/mcp/package-info.java diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInvoker.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInvoker.java index 47a9c295f6..40fea24ce3 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInvoker.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInvoker.java @@ -7,18 +7,73 @@ import io.jooby.SneakyThrows; +/** + * Intercepts and wraps the execution of MCP (Model Context Protocol) operations, such as tools, + * prompts, resources, and completions. + * + *

The {@link McpInvoker} acts as a middleware or decorator around the generated MCP routing + * logic. It allows you to seamlessly inject cross-cutting concerns—such as telemetry, logging + * (SLF4J MDC), transaction management, or custom error handling—right before and after an operation + * executes. + * + *

Chaining Invokers

+ * + *

Jooby provides a default internal invoker that gracefully maps standard framework exceptions + * to MCP JSON-RPC errors. When you register a custom invoker via {@link + * io.jooby.mcp.McpModule#invoker(McpInvoker)}, the framework automatically chains your custom + * invoker with the default one using the {@link #then(McpInvoker)} method. + * + *

Example: MDC Context Propagation

+ * + *
{@code
+ * public class MdcMcpInvoker implements McpInvoker {
+ * public  R invoke(String operationId, SneakyThrows.Supplier action) {
+ * try {
+ * MDC.put("mcp.operation", operationId);
+ * // Execute the actual tool or proceed to the next invoker in the chain
+ * return action.get();
+ * } finally {
+ * MDC.remove("mcp.operation");
+ * }
+ * }
+ * }
+ * * // Register and automatically chain it:
+ * install(new McpModule(new MyServiceMcp_())
+ * .invoker(new MdcMcpInvoker()));
+ * }
+ * + * @author edgar + * @since 4.2.0 + */ public interface McpInvoker { + /** + * Executes the given MCP operation. + * + * @param operationId The identifier of the operation being executed. Typically formatted as + * {@code [type]/[name]} (e.g., {@code "tools/add_numbers"}, {@code "prompts/greeting"}, or + * {@code "resources/file://config"}). + * @param action The actual execution of the operation, or the next invoker in the chain. Must be + * invoked via {@link SneakyThrows.Supplier#get()} to proceed. + * @param The return type of the operation. + * @return The result of the operation. + */ R invoke(String operationId, SneakyThrows.Supplier action); /** * Chains this invoker with another one. This invoker runs first, and its "action" becomes calling * the next invoker. * + *

This is used internally by {@link io.jooby.mcp.McpModule} to compose user-provided invokers + * with the default framework exception mappers. + * * @param next The next invoker in the chain. * @return A composed invoker. */ default McpInvoker then(McpInvoker next) { + if (next == null) { + return this; + } return new McpInvoker() { @Override public R invoke(String operationId, SneakyThrows.Supplier action) { diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java index f2ce43a0ef..1131025425 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java @@ -34,85 +34,108 @@ /** * MCP (Model Context Protocol) module for Jooby. * - *

The MCP module provides integration with the Model Context Protocol server, enabling - * standardized communication between clients and servers. It allows applications to: + *

The MCP module provides seamless integration with the Model Context Protocol, allowing your + * application to act as a standardized AI context server. It automatically bridges your Java/Kotlin + * methods with LLM clients by exposing them as Tools, Resources, and Prompts. + * + *

Key Features

* *
    - *
  • Expose server capabilities as tools, resources, and prompts - *
  • Handle client connections and sessions via SSE - *
  • Process protocol messages and events - *
  • Manage server capabilities and tool specifications + *
  • Compile-Time Discovery: Automatically generates routing logic for {@code @McpTool}, + * {@code @McpPrompt}, and {@code @McpResource} annotations with zero reflection overhead via + * APT. + *
  • Rich Schema Generation: Tool and parameter descriptions are extracted directly from + * your MCP annotations, gracefully falling back to standard JavaDoc comments if omitted. + *
  • Transport Flexibility: Supports {@link Transport#STREAMABLE_HTTP} (default), {@link + * Transport#SSE}, {@link Transport#WEBSOCKET}, and {@link + * Transport#STATELESS_STREAMABLE_HTTP}. + *
  • Execution Interception: Chain custom {@link McpInvoker} instances to seamlessly + * inject MDC context, telemetry, or custom error handling around executions. + *
  • LLM Self-Healing: Automatically catches internal business exceptions and translates + * them into valid MCP error payloads, allowing LLMs to auto-correct their own mistakes. *
* - *

Usage

+ *

Basic Usage

+ * + *

By default, the module requires zero configuration in {@code application.conf} and will spin + * up a single {@code streamable-http} server. * - *

Add the module to your application: + *

The module relies on Jackson for JSON-RPC message serialization. Here is the standard setup + * using Jackson 3: * *

{@code
  * {
- *   install(new JacksonModule());
- *   install(new McpModule(new DefaultMcpServer()));
+ *  // 1. Install Jackson 3 support
+ *  install(new Jackson3Module());
+ *  install(new McpJackson3Module());
+ *  // 2. Install the MCP module with your APT-generated McpService
+ *  install(new McpModule(new MyServiceMcp_()));
  * }
  * }
* - *

Configuration

+ * Note: If your project still uses Jackson 2, simply swap the modules to {@code install(new + * JacksonModule());} and {@code install(new McpJackson2Module());}. * - *

The module requires the following configuration in your application.conf: + *

Changing the Default Transport

+ * + *

If you want to use a different transport protocol for the default server, you can configure it + * directly in the Java DSL: * *

{@code
- * mcp.default {
- *     name: "my-awesome-mcp-server"     # Required
- *     version: "0.0.1"                  # Required
- *     sseEndpoint: "/mcp/sse"           # Optional (default: /mcp/sse)
- *     messageEndpoint: "/mcp/message"   # Optional (default: /mcp/message)
+ * {
+ * install(new McpModule(new MyServiceMcp_())
+ * .transport(Transport.SSE)); // Or Transport.WEBSOCKET, Transport.STATELESS_STREAMABLE_HTTP
  * }
  * }
* - *

Features

+ *

Custom Invokers & Telemetry

* - *
    - *
  • MCP server implementation with SSE transport - *
  • Tools Auto-discovery at build time - *
  • Server capabilities configuration - *
  • Configurable endpoints - *
  • Multiple servers support - *
+ *

You can inject custom logic (like SLF4J MDC context propagation or Tracing spans) around every + * tool, prompt, or resource call by providing a custom {@link McpInvoker}: + * + *

{@code
+ * {
+ * install(new McpModule(new MyServiceMcp_())
+ * .invoker(new MyCustomMdcInvoker())); // Chains automatically with the Default Exception Mapper
+ * }
+ * }
+ * + *

Multiple Servers

* - *

Multiple servers

+ *

The generated {@link McpService} instances do not represent servers themselves; they are + * mapped to specific server instances using the {@code @McpServer("serverKey")} annotation on your + * original class. * - *

To run multiple MCP server instances in the same application, use a @McpServer("calculator") - * annotation: + *

If you route services to multiple, isolated servers, you must define their specific + * configurations in your {@code application.conf}: * *

{@code
  * {
- *
- *   install(new JacksonModule());
- *   install(new McpModule(new DefaultMcpServer(), new CalculatorMcpServer()));
+ * // Jooby will boot two separate MCP servers based on the @McpServer mapping of these services
+ * install(new McpModule(new DefaultServiceMcp_(), new CalculatorServiceMcp_()));
  * }
  * }
* - *

Each instance requires its own configuration block: + *

{@code application.conf}: * *

{@code
- * mcp {
- *  default {
- *    name: "default-mcp-server"
- *    version: "1.0.0"
- *    sseEndpoint: "/mcp/sse"
- *    messageEndpoint: "/mcp/message"
- *  }
- *  calculator {
- *    name: "calculator-mcp-server"
- *    version: "1.0.0"
- *    sseEndpoint: "/mcp/calculator/sse"
- *    messageEndpoint: "/mcp/calculator/message"
- *  }
+ * mcp.calculator {
+ * name: "calculator-mcp-server"
+ * version: "1.0.0"
+ * transport: "web-socket"
+ * mcpEndpoint: "/mcp/calculator/ws"
  * }
- *
  * }
* + *

Testing and Debugging

+ * + *

For local development, Jooby provides a built-in UI to test your AI capabilities. Simply + * install the {@link McpInspectorModule} alongside this module to interactively execute your tools, + * prompts, and resources straight from your browser. + * * @author kliushnichenko - * @since 1.0.0 + * @author edgar + * @since 4.2.0 */ public class McpModule implements Extension { diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpResult.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpResult.java index 41cbc7b492..175712898c 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpResult.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpResult.java @@ -17,14 +17,55 @@ import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; +/** + * Result mapping utility for the Model Context Protocol (MCP) integration. + * + *

This class acts as the bridge between standard Java/Kotlin return types and the strict + * JSON-RPC payload structures required by the MCP specification. It is utilized heavily by the + * APT-generated routing classes ({@code *Mcp_}) to seamlessly translate user-defined method outputs + * into valid protocol responses. + * + *

Conversion Strategies

+ * + *
    + *
  • Pass-through: If a method returns a native MCP schema object (e.g., {@link + * McpSchema.CallToolResult}, {@link McpSchema.GetPromptResult}), it is returned as-is. + *
  • Primitives & Strings: Standard strings and primitives are automatically wrapped in + * the appropriate textual content blocks (e.g., {@link McpSchema.TextContent}). + *
  • POJOs & Collections: Complex objects and lists are automatically serialized into + * JSON strings using the configured {@link McpJsonMapper}, or passed as structured content + * depending on the tool's schema capabilities. + *
  • Prompts: Raw strings or lists returned by a Prompt handler are automatically wrapped + * in a {@link McpSchema.PromptMessage} assigned to the {@link McpSchema.Role#USER}. + *
+ * + *

By handling these conversions internally, developers can write natural, idiomatic Java/Kotlin + * methods without needing to couple their business logic to the MCP SDK classes. + * + * @author edgar + * @since 4.2.0 + */ public class McpResult { private final McpJsonMapper json; + /** + * Creates a new result mapper using the provided JSON mapper for object serialization. + * + * @param json The JSON mapper instance. + */ public McpResult(McpJsonMapper json) { this.json = json; } + /** + * Converts a raw method return value into an MCP tool result. + * + * @param result The raw return value from the tool execution. + * @param structuredContent True if complex objects should be returned as structured data objects, + * false to serialize them as JSON text. + * @return A valid {@link McpSchema.CallToolResult}. + */ public McpSchema.CallToolResult toCallToolResult(Object result, boolean structuredContent) { try { if (result == null) { @@ -50,6 +91,12 @@ public McpSchema.CallToolResult toCallToolResult(Object result, boolean structur } } + /** + * Converts a raw method return value into an MCP prompt result. + * + * @param result The raw return value from the prompt execution. + * @return A valid {@link McpSchema.GetPromptResult}. + */ public McpSchema.GetPromptResult toPromptResult(Object result) { if (result == null) { return new McpSchema.GetPromptResult(null, List.of()); @@ -73,6 +120,13 @@ public McpSchema.GetPromptResult toPromptResult(Object result) { } } + /** + * Converts a raw method return value into an MCP resource result. + * + * @param uri The requested resource URI. + * @param result The raw return value from the resource execution. + * @return A valid {@link McpSchema.ReadResourceResult}. + */ public McpSchema.ReadResourceResult toResourceResult(String uri, Object result) { try { if (result == null) { @@ -91,6 +145,12 @@ public McpSchema.ReadResourceResult toResourceResult(String uri, Object result) } } + /** + * Converts a raw method return value into an MCP completion result. + * + * @param result The raw return value from the completion execution. + * @return A valid {@link McpSchema.CompleteResult}. + */ public McpSchema.CompleteResult toCompleteResult(Object result) { try { Objects.requireNonNull(result, "Completion result cannot be null"); diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java index ceab965a7a..aac6eb5b8e 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java @@ -14,19 +14,78 @@ import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.spec.McpSchema; -/** High-performance dispatcher interface generated by the Jooby APT for MCP endpoints. */ +/** + * High-performance dispatcher interface generated by the Jooby APT for MCP endpoints. + * + *

This interface serves as the primary bridge between the Model Context Protocol (MCP) SDK and + * your user-defined Java/Kotlin methods. You generally do not implement this interface manually; + * instead, the Jooby Annotation Processor (APT) generates implementations automatically (typically + * suffixed with {@code Mcp_}) for any class containing {@code @McpTool}, {@code @McpPrompt}, or + * {@code @McpResource} annotations. + * + *

These generated services are then passed to the {@link io.jooby.mcp.McpModule} to be wired + * into the application lifecycle. + * + * @author edgar + * @since 4.2.0 + */ public interface McpService { + /** + * Installs and registers the discovered tools, prompts, and resources into a stateful MCP server. + * + * @param application The current Jooby application instance. + * @param server The stateful MCP server registry. + * @throws Exception If an error occurs during route registration or schema generation. + */ void install(Jooby application, McpSyncServer server) throws Exception; + /** + * Installs and registers the discovered tools, prompts, and resources into a stateless MCP + * server. + * + * @param application The current Jooby application instance. + * @param server The stateless MCP server registry. + * @throws Exception If an error occurs during route registration or schema generation. + */ void install(Jooby application, McpStatelessSyncServer server) throws Exception; + /** + * Populates the server capabilities builder with the features supported by this service. * + * + *

The generated implementation will dynamically enable the tools, prompts, or resources flags + * depending on which annotations were present in the source class. + * + * @param capabilities The MCP server capabilities builder. + */ void capabilities(McpSchema.ServerCapabilities.Builder capabilities); + /** + * Generates the auto-completion specifications for arguments in a stateful server environment. + * + * @param application The current Jooby application instance used to resolve the {@link + * McpInvoker}. + * @return A list of synchronous completion specifications for tools, prompts, or resources. + */ List completions(Jooby application); + /** + * Generates the auto-completion specifications for arguments in a stateless server environment. + * + * @param application The current Jooby application instance used to resolve the {@link + * McpInvoker}. + * @return A list of synchronous completion specifications for tools, prompts, or resources. + */ List statelessCompletions( Jooby application); + /** + * Retrieves the server identifier this service is bound to. * + * + *

This correlates directly to the {@code @McpServer("name")} annotation. If no explicit server + * name is provided, this defaults to {@code "default"}. + * + * @return The target server key. + */ String serverKey(); } diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/package-info.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/package-info.java new file mode 100644 index 0000000000..339dc9ebe1 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/package-info.java @@ -0,0 +1,109 @@ +/** + * MCP (Model Context Protocol) module for Jooby. + * + *

The MCP module provides seamless integration with the Model Context Protocol, allowing your + * application to act as a standardized AI context server. It automatically bridges your Java/Kotlin + * methods with LLM clients by exposing them as Tools, Resources, and Prompts. + * + *

Key Features

+ * + *
    + *
  • Compile-Time Discovery: Automatically generates routing logic for {@code @McpTool}, + * {@code @McpPrompt}, and {@code @McpResource} annotations with zero reflection overhead via + * APT. + *
  • Rich Schema Generation: Tool and parameter descriptions are extracted directly from + * your MCP annotations, gracefully falling back to standard JavaDoc comments if omitted. + *
  • Transport Flexibility: Supports {@link + * io.jooby.mcp.McpModule.Transport#STREAMABLE_HTTP} (default), {@link + * io.jooby.mcp.McpModule.Transport#SSE}, {@link io.jooby.mcp.McpModule.Transport#WEBSOCKET}, + * and {@link io.jooby.mcp.McpModule.Transport#STATELESS_STREAMABLE_HTTP}. + *
  • Execution Interception: Chain custom {@link io.jooby.mcp.McpInvoker} instances to + * seamlessly inject MDC context, telemetry, or custom error handling around executions. + *
  • LLM Self-Healing: Automatically catches internal business exceptions and translates + * them into valid MCP error payloads, allowing LLMs to auto-correct their own mistakes. + *
+ * + *

Basic Usage

+ * + *

By default, the module requires zero configuration in {@code application.conf} and will spin + * up a single {@code streamable-http} server. + * + *

The module relies on Jackson for JSON-RPC message serialization. Here is the standard setup + * using Jackson 3: + * + *

{@code
+ * {
+ *  // 1. Install Jackson 3 support
+ *  install(new Jackson3Module());
+ *  install(new McpJackson3Module());
+ *  // 2. Install the MCP module with your APT-generated McpService
+ *  install(new McpModule(new MyServiceMcp_()));
+ * }
+ * }
+ * + * Note: If your project still uses Jackson 2, simply swap the modules to {@code install(new + * JacksonModule());} and {@code install(new McpJackson2Module());}. + * + *

Changing the Default Transport

+ * + *

If you want to use a different transport protocol for the default server, you can configure it + * directly in the Java DSL: + * + *

{@code
+ * {
+ * install(new McpModule(new MyServiceMcp_())
+ * .transport(Transport.SSE)); // Or Transport.WEBSOCKET, Transport.STATELESS_STREAMABLE_HTTP
+ * }
+ * }
+ * + *

Custom Invokers & Telemetry

+ * + *

You can inject custom logic (like SLF4J MDC context propagation or Tracing spans) around every + * tool, prompt, or resource call by providing a custom {@link io.jooby.mcp.McpInvoker}: + * + *

{@code
+ * {
+ * install(new McpModule(new MyServiceMcp_())
+ * .invoker(new MyCustomMdcInvoker())); // Chains automatically with the Default Exception Mapper
+ * }
+ * }
+ * + *

Multiple Servers

+ * + *

The generated {@link io.jooby.mcp.McpService} instances do not represent servers themselves; + * they are mapped to specific server instances using the {@code @McpServer("serverKey")} annotation + * on your original class. + * + *

If you route services to multiple, isolated servers, you must define their specific + * configurations in your {@code application.conf}: + * + *

{@code
+ * {
+ * // Jooby will boot two separate MCP servers based on the @McpServer mapping of these services
+ * install(new McpModule(new DefaultServiceMcp_(), new CalculatorServiceMcp_()));
+ * }
+ * }
+ * + *

{@code application.conf}: + * + *

{@code
+ * mcp.calculator {
+ * name: "calculator-mcp-server"
+ * version: "1.0.0"
+ * transport: "web-socket"
+ * mcpEndpoint: "/mcp/calculator/ws"
+ * }
+ * }
+ * + *

Testing and Debugging

+ * + *

For local development, Jooby provides a built-in UI to test your AI capabilities. Simply + * install the {@link io.jooby.mcp.McpInspectorModule} alongside this module to interactively + * execute your tools, prompts, and resources straight from your browser. + * + * @author kliushnichenko + * @author edgar + * @since 4.2.0 + */ +@edu.umd.cs.findbugs.annotations.ReturnValuesAreNonnullByDefault +package io.jooby.mcp; From f722ce8c0665a933bc927ea145c81c501d345c2f Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 30 Mar 2026 17:49:20 -0300 Subject: [PATCH 34/37] - add default loging to fallback invoker --- .../java/io/jooby/internal/apt/McpRouter.java | 83 +++++++++++++++---- .../src/test/java/tests/i3830/Issue3830.java | 24 +++--- .../jooby/internal/mcp/DefaultMcpInvoker.java | 14 +++- .../java/io/jooby/mcp/McpInspectorModule.java | 5 +- .../main/java/io/jooby/mcp/McpInvoker.java | 10 +-- .../src/main/java/io/jooby/mcp/McpModule.java | 45 +++++++++- .../main/java/io/jooby/mcp/McpOperation.java | 17 ++++ 7 files changed, 158 insertions(+), 40 deletions(-) create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/mcp/McpOperation.java diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java index 5357edfe0c..6ca075efc6 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java @@ -114,6 +114,7 @@ private String findTargetMethodName(String ref) { @Override public String toSourceCode(boolean kt) throws IOException { var generateTypeName = getTargetType().getSimpleName().toString(); + var targetClassName = getTargetType().toString(); var mcpClassName = getGeneratedType().substring(getGeneratedType().lastIndexOf('.') + 1); var packageName = getPackageName(); @@ -280,17 +281,36 @@ public String toSourceCode(boolean kt) throws IOException { String lambda; if (completionGroups.containsKey(ref)) { - var handlerName = findTargetMethodName(ref) + "CompletionHandler"; + var targetMethod = findTargetMethodName(ref); + var handlerName = targetMethod + "CompletionHandler"; var operationId = "completions/" + ref; + + String operationArg = + kt + ? "io.jooby.mcp.McpOperation(" + + string(operationId) + + ", " + + string(targetClassName) + + ", " + + string(targetMethod) + + ")" + : "new io.jooby.mcp.McpOperation(" + + string(operationId) + + ", " + + string(targetClassName) + + ", " + + string(targetMethod) + + ")"; + lambda = kt ? "{ exchange, req -> invoker.invoke(" - + string(operationId) + + operationArg + ") { this." + handlerName + "(exchange, exchange.transportContext(), req) } }" : "(exchange, req) -> invoker.invoke(" - + string(operationId) + + operationArg + ", () -> this." + handlerName + "(exchange, exchange.transportContext(), req))"; @@ -377,17 +397,36 @@ public String toSourceCode(boolean kt) throws IOException { String lambda; if (completionGroups.containsKey(ref)) { - var handlerName = findTargetMethodName(ref) + "CompletionHandler"; + var targetMethod = findTargetMethodName(ref); + var handlerName = targetMethod + "CompletionHandler"; var operationId = "completions/" + ref; + + var operationArg = + kt + ? "io.jooby.mcp.McpOperation(" + + string(operationId) + + ", " + + string(targetClassName) + + ", " + + string(targetMethod) + + ")" + : "new io.jooby.mcp.McpOperation(" + + string(operationId) + + ", " + + string(targetClassName) + + ", " + + string(targetMethod) + + ")"; + lambda = kt ? "{ ctx, req -> invoker.invoke(" - + string(operationId) + + operationArg + ") { this." + handlerName + "(null, ctx, req) } }" : "(ctx, req) -> invoker.invoke(" - + string(operationId) + + operationArg + ", () -> this." + handlerName + "(null, ctx, req))"; @@ -530,31 +569,47 @@ public String toSourceCode(boolean kt) throws IOException { .orElse("") : ""; } - if (mcpName == null || mcpName.isEmpty()) mcpName = methodName; - String operationId = mcpType + "/" + mcpName; + if (mcpName.isEmpty()) mcpName = methodName; + var operationId = mcpType + "/" + mcpName; + + var operationArg = + kt + ? "io.jooby.mcp.McpOperation(" + + string(operationId) + + ", " + + string(targetClassName) + + ", " + + string(methodName) + + ")" + : "new io.jooby.mcp.McpOperation(" + + string(operationId) + + ", " + + string(targetClassName) + + ", " + + string(methodName) + + ")"; - // --- Lambda Router Definition --- - String lambda = + var lambda = kt ? (isStateless ? "{ ctx, req -> invoker.invoke(" - + string(operationId) + + operationArg + ") { this." + methodName + "(null, ctx, req) } }" : "{ exchange, req -> invoker.invoke(" - + string(operationId) + + operationArg + ") { this." + methodName + "(exchange, exchange.transportContext(), req) } }") : (isStateless ? "(ctx, req) -> invoker.invoke(" - + string(operationId) + + operationArg + ", () -> this." + methodName + "(null, ctx, req))" : "(exchange, req) -> invoker.invoke(" - + string(operationId) + + operationArg + ", () -> this." + methodName + "(exchange, exchange.transportContext(), req))"); diff --git a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java index a7daba8c47..d3eb09aa25 100644 --- a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java +++ b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java @@ -64,8 +64,8 @@ public String serverKey() { public java.util.List completions(io.jooby.Jooby app) { var invoker = app.require(io.jooby.mcp.McpInvoker.class); var completions = new java.util.ArrayList(); - completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), (exchange, req) -> invoker.invoke("completions/review_code", () -> this.reviewCodeCompletionHandler(exchange, exchange.transportContext(), req)))); - completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.ResourceReference("file:///users/{id}/{name}/profile"), (exchange, req) -> invoker.invoke("completions/file:///users/{id}/{name}/profile", () -> this.getUserProfileCompletionHandler(exchange, exchange.transportContext(), req)))); + completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), (exchange, req) -> invoker.invoke(new io.jooby.mcp.McpOperation("completions/review_code", "tests.i3830.ExampleServer", "reviewCode"), () -> this.reviewCodeCompletionHandler(exchange, exchange.transportContext(), req)))); + completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.ResourceReference("file:///users/{id}/{name}/profile"), (exchange, req) -> invoker.invoke(new io.jooby.mcp.McpOperation("completions/file:///users/{id}/{name}/profile", "tests.i3830.ExampleServer", "getUserProfile"), () -> this.getUserProfileCompletionHandler(exchange, exchange.transportContext(), req)))); return completions; } @@ -73,8 +73,8 @@ public java.util.List statelessCompletions(io.jooby.Jooby app) { var invoker = app.require(io.jooby.mcp.McpInvoker.class); var completions = new java.util.ArrayList(); - completions.add(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), (ctx, req) -> invoker.invoke("completions/review_code", () -> this.reviewCodeCompletionHandler(null, ctx, req)))); - completions.add(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.ResourceReference("file:///users/{id}/{name}/profile"), (ctx, req) -> invoker.invoke("completions/file:///users/{id}/{name}/profile", () -> this.getUserProfileCompletionHandler(null, ctx, req)))); + completions.add(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), (ctx, req) -> invoker.invoke(new io.jooby.mcp.McpOperation("completions/review_code", "tests.i3830.ExampleServer", "reviewCode"), () -> this.reviewCodeCompletionHandler(null, ctx, req)))); + completions.add(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.ResourceReference("file:///users/{id}/{name}/profile"), (ctx, req) -> invoker.invoke(new io.jooby.mcp.McpOperation("completions/file:///users/{id}/{name}/profile", "tests.i3830.ExampleServer", "getUserProfile"), () -> this.getUserProfileCompletionHandler(null, ctx, req)))); return completions; } @@ -84,10 +84,10 @@ public void install(io.jooby.Jooby app, io.modelcontextprotocol.server.McpSyncSe var invoker = app.require(io.jooby.mcp.McpInvoker.class); var schemaGenerator = app.require(com.github.victools.jsonschema.generator.SchemaGenerator.class); - server.addTool(new io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification(addToolSpec(schemaGenerator), (exchange, req) -> invoker.invoke("tools/calculator", () -> this.add(exchange, exchange.transportContext(), req)))); - server.addPrompt(new io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification(reviewCodePromptSpec(), (exchange, req) -> invoker.invoke("prompts/review_code", () -> this.reviewCode(exchange, exchange.transportContext(), req)))); - server.addResource(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification(getLogsResourceSpec(), (exchange, req) -> invoker.invoke("resources/file:///logs/app.log", () -> this.getLogs(exchange, exchange.transportContext(), req)))); - server.addResourceTemplate(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceTemplateSpecification(getUserProfileResourceTemplateSpec(), (exchange, req) -> invoker.invoke("resources/file:///users/{id}/{name}/profile", () -> this.getUserProfile(exchange, exchange.transportContext(), req)))); + server.addTool(new io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification(addToolSpec(schemaGenerator), (exchange, req) -> invoker.invoke(new io.jooby.mcp.McpOperation("tools/calculator", "tests.i3830.ExampleServer", "add"), () -> this.add(exchange, exchange.transportContext(), req)))); + server.addPrompt(new io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification(reviewCodePromptSpec(), (exchange, req) -> invoker.invoke(new io.jooby.mcp.McpOperation("prompts/review_code", "tests.i3830.ExampleServer", "reviewCode"), () -> this.reviewCode(exchange, exchange.transportContext(), req)))); + server.addResource(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification(getLogsResourceSpec(), (exchange, req) -> invoker.invoke(new io.jooby.mcp.McpOperation("resources/file:///logs/app.log", "tests.i3830.ExampleServer", "getLogs"), () -> this.getLogs(exchange, exchange.transportContext(), req)))); + server.addResourceTemplate(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceTemplateSpecification(getUserProfileResourceTemplateSpec(), (exchange, req) -> invoker.invoke(new io.jooby.mcp.McpOperation("resources/file:///users/{id}/{name}/profile", "tests.i3830.ExampleServer", "getUserProfile"), () -> this.getUserProfile(exchange, exchange.transportContext(), req)))); } @Override @@ -96,10 +96,10 @@ public void install(io.jooby.Jooby app, io.modelcontextprotocol.server.McpStatel var invoker = app.require(io.jooby.mcp.McpInvoker.class); var schemaGenerator = app.require(com.github.victools.jsonschema.generator.SchemaGenerator.class); - server.addTool(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncToolSpecification(addToolSpec(schemaGenerator), (ctx, req) -> invoker.invoke("tools/calculator", () -> this.add(null, ctx, req)))); - server.addPrompt(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncPromptSpecification(reviewCodePromptSpec(), (ctx, req) -> invoker.invoke("prompts/review_code", () -> this.reviewCode(null, ctx, req)))); - server.addResource(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceSpecification(getLogsResourceSpec(), (ctx, req) -> invoker.invoke("resources/file:///logs/app.log", () -> this.getLogs(null, ctx, req)))); - server.addResourceTemplate(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceTemplateSpecification(getUserProfileResourceTemplateSpec(), (ctx, req) -> invoker.invoke("resources/file:///users/{id}/{name}/profile", () -> this.getUserProfile(null, ctx, req)))); + server.addTool(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncToolSpecification(addToolSpec(schemaGenerator), (ctx, req) -> invoker.invoke(new io.jooby.mcp.McpOperation("tools/calculator", "tests.i3830.ExampleServer", "add"), () -> this.add(null, ctx, req)))); + server.addPrompt(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncPromptSpecification(reviewCodePromptSpec(), (ctx, req) -> invoker.invoke(new io.jooby.mcp.McpOperation("prompts/review_code", "tests.i3830.ExampleServer", "reviewCode"), () -> this.reviewCode(null, ctx, req)))); + server.addResource(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceSpecification(getLogsResourceSpec(), (ctx, req) -> invoker.invoke(new io.jooby.mcp.McpOperation("resources/file:///logs/app.log", "tests.i3830.ExampleServer", "getLogs"), () -> this.getLogs(null, ctx, req)))); + server.addResourceTemplate(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceTemplateSpecification(getUserProfileResourceTemplateSpec(), (ctx, req) -> invoker.invoke(new io.jooby.mcp.McpOperation("resources/file:///users/{id}/{name}/profile", "tests.i3830.ExampleServer", "getUserProfile"), () -> this.getUserProfile(null, ctx, req)))); } private io.modelcontextprotocol.spec.McpSchema.Tool addToolSpec(com.github.victools.jsonschema.generator.SchemaGenerator schemaGenerator) { diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/DefaultMcpInvoker.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/DefaultMcpInvoker.java index 18751bdeeb..ea10f0158f 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/DefaultMcpInvoker.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/DefaultMcpInvoker.java @@ -5,10 +5,14 @@ */ package io.jooby.internal.mcp; +import org.slf4j.LoggerFactory; + +import edu.umd.cs.findbugs.annotations.NonNull; import io.jooby.Jooby; import io.jooby.SneakyThrows; import io.jooby.StatusCode; import io.jooby.mcp.McpInvoker; +import io.jooby.mcp.McpOperation; import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; @@ -21,19 +25,25 @@ public DefaultMcpInvoker(Jooby application) { @SuppressWarnings("unchecked") @Override - public R invoke(String operationId, SneakyThrows.Supplier action) { + public @NonNull R invoke(McpOperation operation, SneakyThrows.Supplier action) { try { return action.get(); } catch (McpError mcpError) { throw mcpError; } catch (Throwable cause) { - if (operationId.startsWith("tools/")) { + var log = LoggerFactory.getLogger(operation.className()); + if (operation.id().startsWith("tools/")) { // Tool error var errorMessage = cause.getMessage() != null ? cause.getMessage() : cause.toString(); return (R) McpSchema.CallToolResult.builder().addTextContent(errorMessage).isError(true).build(); } var statusCode = application.getRouter().errorCode(cause); + if (statusCode.value() >= 500) { + log.error("execution of {} resulted in exception", operation.id(), cause); + } else { + log.debug("execution of {} resulted in exception", operation.id(), cause); + } var mcpErrorCode = toMcpErrorCode(statusCode); throw new McpError( new McpSchema.JSONRPCResponse.JSONRPCError(mcpErrorCode, cause.getMessage(), null)); diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInspectorModule.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInspectorModule.java index 71a5a794ae..1fe85baff6 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInspectorModule.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInspectorModule.java @@ -171,12 +171,13 @@ private McpServerConfig resolveMcpServerConfig(Jooby app) { new StartupException("MCP server named '%s' not found".formatted(defaultServer))); } - return srvConfigs.get(0); + return srvConfigs.getFirst(); } private String buildConfigJson(McpServerConfig config, String location) { var endpoint = resolveEndpoint(config); - var transport = config.getTransport(); + var transport = + config.isSseTransport() ? McpModule.Transport.SSE : McpModule.Transport.STREAMABLE_HTTP; return """ { "defaultEnvironment": { diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInvoker.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInvoker.java index 40fea24ce3..9b8c56a61e 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInvoker.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInvoker.java @@ -50,15 +50,13 @@ public interface McpInvoker { /** * Executes the given MCP operation. * - * @param operationId The identifier of the operation being executed. Typically formatted as - * {@code [type]/[name]} (e.g., {@code "tools/add_numbers"}, {@code "prompts/greeting"}, or - * {@code "resources/file://config"}). + * @param operation The operation being executed. * @param action The actual execution of the operation, or the next invoker in the chain. Must be * invoked via {@link SneakyThrows.Supplier#get()} to proceed. * @param The return type of the operation. * @return The result of the operation. */ - R invoke(String operationId, SneakyThrows.Supplier action); + R invoke(McpOperation operation, SneakyThrows.Supplier action); /** * Chains this invoker with another one. This invoker runs first, and its "action" becomes calling @@ -76,8 +74,8 @@ default McpInvoker then(McpInvoker next) { } return new McpInvoker() { @Override - public R invoke(String operationId, SneakyThrows.Supplier action) { - return McpInvoker.this.invoke(operationId, () -> next.invoke(operationId, action)); + public R invoke(McpOperation operation, SneakyThrows.Supplier action) { + return McpInvoker.this.invoke(operation, () -> next.invoke(operation, action)); } }; } diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java index 1131025425..1b546e456c 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java @@ -154,6 +154,16 @@ public class McpModule implements Extension { private McpInvoker invoker; + /** + * Creates a new MCP module initialized with the provided generated services. + * + *

The services passed to this constructor are typically generated at compile-time by the Jooby + * Annotation Processor (APT) for classes containing {@code @McpTool}, {@code @McpPrompt}, or + * {@code @McpResource} annotations. + * + * @param mcpService The primary generated MCP service (usually suffixed with {@code Mcp_}). + * @param mcpServices Optional additional generated MCP services to register. + */ public McpModule(McpService mcpService, McpService... mcpServices) { this.mcpServices.add(mcpService); if (mcpServices != null) { @@ -161,13 +171,40 @@ public McpModule(McpService mcpService, McpService... mcpServices) { } } + /** + * Overrides the default transport protocol used by the MCP server. + * + *

If not explicitly called, the module defaults to {@link Transport#STREAMABLE_HTTP}. This + * setting applies to the default server instance and can be overridden on a per-server basis via + * your {@code application.conf}. + * + * @param transport The desired default transport protocol. + * @return This module instance for method chaining. + */ public McpModule transport(@NonNull Transport transport) { this.defaultTransport = transport; return this; } + /** + * Registers a custom {@link McpInvoker} to intercept and wrap MCP operations. + * + *

This method allows you to inject cross-cutting concerns—such as logging, tracing, or SLF4J + * MDC context propagation—around your tools, prompts, and resources. + * + *

Chaining: If called multiple times, the newly provided invoker is chained + * before the previously registered invokers. Ultimately, all custom invokers are + * automatically chained just before the framework's default exception-mapping invoker. + * + * @param invoker The custom invoker to register. + * @return This module instance for method chaining. + */ public McpModule invoker(@NonNull McpInvoker invoker) { - this.invoker = invoker; + if (this.invoker != null) { + this.invoker = invoker.then(this.invoker); + } else { + this.invoker = invoker; + } return this; } @@ -176,11 +213,11 @@ public void install(@NonNull Jooby app) { var services = app.getServices(); var mcpJsonMapper = services.require(McpJsonMapper.class); // invoker - McpInvoker invoker = new DefaultMcpInvoker(app); + McpInvoker firstInvoker = new DefaultMcpInvoker(app); if (this.invoker != null) { - invoker = invoker.then(this.invoker); + firstInvoker = firstInvoker.then(this.invoker); } - services.put(McpInvoker.class, invoker); + services.put(McpInvoker.class, firstInvoker); // Group services by server var mcpServiceMap = new HashMap>(); for (var mcpService : mcpServices) { diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpOperation.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpOperation.java new file mode 100644 index 0000000000..8ecd292b80 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpOperation.java @@ -0,0 +1,17 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.mcp; + +/** + * Contextual information about an MCP operation being invoked. + * + * @param id The standard MCP identifier (e.g., "tools/add_numbers"). + * @param className The fully qualified name of the Java/Kotlin class hosting the method. + * @param methodName The name of the Java/Kotlin method being executed. + * @author edgar + * @since 4.2.0 + */ +public record McpOperation(String id, String className, String methodName) {} From b773ad58d4200075a239cf53c0705be97804fea1 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 30 Mar 2026 19:30:02 -0300 Subject: [PATCH 35/37] feat(mcp): introduce @McpOutputSchema and runtime generation controls To conserve LLM context window tokens and avoid recursive reflection on complex return types, this commit makes JSON output schema generation for MCP tools configurable and strictly opt-in. * Adds a stateful `generateOutputSchema` flag to the APT-generated `McpService` classes (defaults to false). This allows developers to toggle global output schema generation at runtime via the `McpModule`. * Introduces the top-level `@McpOutputSchema` annotation for granular, per-tool overrides, independent of the global flag. - `@McpOutputSchema.Off`: Explicitly disables schema generation. - `@McpOutputSchema.From(Class)`: Forces generation using a specific type, elegantly bypassing Java type erasure (e.g., for generic Maps). - `@McpOutputSchema.ArrayOf(Class)`: Forces generation as a JSON array. - `@McpOutputSchema.MapOf(Class)`: Forces generation as a JSON object map. * Updates `McpRouter` to inject the stateful flag and setter method into generated routing classes. * Updates `McpRoute` to conditionally generate Victools schema extraction logic based on annotation presence and the runtime fallback flag. --- .../java/io/jooby/internal/apt/McpRoute.java | 197 ++++++++++++++++-- .../java/io/jooby/internal/apt/McpRouter.java | 26 +++ .../src/test/java/tests/i3830/Issue3830.java | 7 + .../java/tests/i3830/OutputSchemaTest.java | 144 +++++++++++++ .../java/tests/i3830/OutputSchemaTools.java | 40 ++++ .../src/test/java/tests/i3830/Pet.java | 8 + .../jooby/annotation/mcp/McpOutputSchema.java | 65 ++++++ .../src/main/java/io/jooby/mcp/McpModule.java | 28 ++- .../main/java/io/jooby/mcp/McpService.java | 2 + .../java/io/jooby/i3830/UserToolsTest.java | 1 + 10 files changed, 494 insertions(+), 24 deletions(-) create mode 100644 modules/jooby-apt/src/test/java/tests/i3830/OutputSchemaTest.java create mode 100644 modules/jooby-apt/src/test/java/tests/i3830/OutputSchemaTools.java create mode 100644 modules/jooby-apt/src/test/java/tests/i3830/Pet.java create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpOutputSchema.java diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java index 18629e5b8b..1fc1e0b963 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java @@ -492,7 +492,6 @@ private List generateToolDefinition(boolean kt) { ")")); } - // Switched from .set() to .put() for standard Map buffer.add(statement(indent(6), "props.put(", string(mcpName), ", schema_", mcpName, ")")); if (!param.isNullable(kt)) { @@ -523,7 +522,6 @@ private List generateToolDefinition(boolean kt) { semicolon(kt))); } - // Switched from .set() to .put() for standard Map buffer.add( statement( indent(6), @@ -540,52 +538,157 @@ private List generateToolDefinition(boolean kt) { } } - // --- OUTPUT SCHEMA GENERATION --- - String returnTypeStr = getReturnType().getRawType().toString(); - boolean generateOutputSchema = hasOutputSchema(); + // --- OUTPUT SCHEMA GENERATION (RUNTIME AWARE) --- + var outMeta = parseOutputSchemaMeta(); + boolean isEligible = hasOutputSchema(); + String outputSchemaArg = "null"; - if (generateOutputSchema) { + if (outMeta.isOff() || (!isEligible && outMeta.type() == null)) { + // Do nothing, outputSchemaArg remains "null" + } else { outputSchemaArg = getMethodName() + "OutputSchema"; + String targetTypeStr = + outMeta.type() != null ? outMeta.type() : getReturnType().getRawType().toString(); + if (kt) { + buffer.add( + statement(indent(6), "var ", outputSchemaArg, ": java.util.Map? = null")); + } else { buffer.add( statement( indent(6), + "java.util.Map ", + outputSchemaArg, + " = null", + semicolon(kt))); + } + + boolean needsRuntimeCheck = (outMeta.type() == null); + String ind = indent(6); + + if (needsRuntimeCheck) { + buffer.add(statement(indent(6), "if (this.generateOutputSchema) {")); + ind = indent(8); + } + + if (kt) { + buffer.add( + statement( + ind, "val ", outputSchemaArg, "Node = schemaGenerator.generateSchema(", - returnTypeStr, + targetTypeStr, "::class.java)")); - // Use this.json to convert the output schema buffer.add( statement( - indent(6), + ind, "val ", outputSchemaArg, - " = this.json.convertValue(", + "Map = this.json.convertValue(", outputSchemaArg, "Node, java.util.Map::class.java) as java.util.Map")); } else { buffer.add( statement( - indent(6), + ind, "var ", outputSchemaArg, "Node = schemaGenerator.generateSchema(", - returnTypeStr, + targetTypeStr, ".class)", semicolon(kt))); - // Use this.json to convert the output schema buffer.add( statement( - indent(6), + ind, "var ", outputSchemaArg, - " = this.json.convertValue(", + "Map = this.json.convertValue(", outputSchemaArg, "Node, java.util.Map.class)", semicolon(kt))); } + + // Handle ArrayOf and MapOf Schema Wrapping + if (outMeta.schemaType() == SchemaType.ARRAY) { + if (kt) { + buffer.add( + statement( + ind, + "val ", + outputSchemaArg, + "Wrapped = java.util.LinkedHashMap()")); + buffer.add(statement(ind, outputSchemaArg, "Wrapped.put(\"type\", \"array\")")); + buffer.add( + statement(ind, outputSchemaArg, "Wrapped.put(\"items\", ", outputSchemaArg, "Map)")); + buffer.add(statement(ind, outputSchemaArg, " = ", outputSchemaArg, "Wrapped")); + } else { + buffer.add( + statement( + ind, + "var ", + outputSchemaArg, + "Wrapped = new java.util.LinkedHashMap()", + semicolon(kt))); + buffer.add( + statement(ind, outputSchemaArg, "Wrapped.put(\"type\", \"array\")", semicolon(kt))); + buffer.add( + statement( + ind, + outputSchemaArg, + "Wrapped.put(\"items\", ", + outputSchemaArg, + "Map)", + semicolon(kt))); + buffer.add( + statement(ind, outputSchemaArg, " = ", outputSchemaArg, "Wrapped", semicolon(kt))); + } + } else if (outMeta.schemaType() == SchemaType.MAP) { + if (kt) { + buffer.add( + statement( + ind, + "val ", + outputSchemaArg, + "Wrapped = java.util.LinkedHashMap()")); + buffer.add(statement(ind, outputSchemaArg, "Wrapped.put(\"type\", \"object\")")); + buffer.add( + statement( + ind, + outputSchemaArg, + "Wrapped.put(\"additionalProperties\", ", + outputSchemaArg, + "Map)")); + buffer.add(statement(ind, outputSchemaArg, " = ", outputSchemaArg, "Wrapped")); + } else { + buffer.add( + statement( + ind, + "var ", + outputSchemaArg, + "Wrapped = new java.util.LinkedHashMap()", + semicolon(kt))); + buffer.add( + statement(ind, outputSchemaArg, "Wrapped.put(\"type\", \"object\")", semicolon(kt))); + buffer.add( + statement( + ind, + outputSchemaArg, + "Wrapped.put(\"additionalProperties\", ", + outputSchemaArg, + "Map)", + semicolon(kt))); + buffer.add( + statement(ind, outputSchemaArg, " = ", outputSchemaArg, "Wrapped", semicolon(kt))); + } + } else { + buffer.add(statement(ind, outputSchemaArg, " = ", outputSchemaArg, "Map", semicolon(kt))); + } + + if (needsRuntimeCheck) { + buffer.add(statement(indent(6), "}")); + } } // --- NESTED ANNOTATION EXTRACTION --- @@ -622,7 +725,6 @@ private List generateToolDefinition(boolean kt) { titleArg, ", ", string(description), - // Use this.json to convert the main schema map into JsonSchema ", this.json.convertValue(schema," + " io.modelcontextprotocol.spec.McpSchema.JsonSchema::class.java), ", outputSchemaArg, @@ -660,7 +762,6 @@ private List generateToolDefinition(boolean kt) { titleArg, ", ", string(description), - // Use this.json to convert the main schema map into JsonSchema ", this.json.convertValue(schema," + " io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), ", outputSchemaArg, @@ -708,7 +809,7 @@ public List generateMcpHandlerMethod(boolean kt) { "private fun ", handlerName, "(exchange: io.modelcontextprotocol.server.McpSyncServerExchange?, transportContext:" - + " io.modelcontextprotocol.common.McpTransportContext, req:" // Removed '?' + + " io.modelcontextprotocol.common.McpTransportContext, req:" + " io.modelcontextprotocol.spec.McpSchema.", reqType, "): io.modelcontextprotocol.spec.McpSchema.", @@ -727,7 +828,7 @@ public List generateMcpHandlerMethod(boolean kt) { handlerName, "(io.modelcontextprotocol.server.McpSyncServerExchange exchange," + " io.modelcontextprotocol.common.McpTransportContext" - + " transportContext," // Guaranteed non-null + + " transportContext," + " io.modelcontextprotocol.spec.McpSchema.", reqType, " req) {")); @@ -969,11 +1070,22 @@ public List generateMcpHandlerMethod(boolean kt) { var methodCall = "c." + getMethodName() + "(" + String.join(", ", javaParamNames) + ")"; - // Prefix for Resources: "req.uri(), " String toMethodPrefix = (isMcpResource() || isMcpResourceTemplate()) ? "req.uri(), " : ""; - // Suffix for Tools: ", true" or ", false" - String toMethodSuffix = isMcpTool() ? ", " + hasOutputSchema() : ""; + // Resolve output schema flag for Handler runtime behavior + String toMethodSuffix = ""; + if (isMcpTool()) { + var outMeta = parseOutputSchemaMeta(); + boolean isEligible = hasOutputSchema(); + + if (outMeta.isOff() || (!isEligible && outMeta.type() == null)) { + toMethodSuffix = ", false"; + } else if (outMeta.type() != null) { + toMethodSuffix = ", true"; + } else { + toMethodSuffix = ", this.generateOutputSchema"; + } + } if (getReturnType().isVoid()) { buffer.add(statement(indent(6), methodCall, semicolon(kt))); @@ -1070,6 +1182,38 @@ private boolean hasOutputSchema() { && !isMcpClass; } + private String extractClassValue(String annotationName) { + var annotation = AnnotationSupport.findAnnotationByName(method, annotationName); + if (annotation == null) return null; + var val = + AnnotationSupport.findAnnotationValue(annotation, "value"::equals).stream() + .findFirst() + .orElse(null); + if (val != null && val.endsWith(".class")) { + return val.substring(0, val.length() - 6); + } + return val; + } + + private OutputSchemaMeta parseOutputSchemaMeta() { + if (AnnotationSupport.findAnnotationByName( + method, "io.jooby.annotation.mcp.McpOutputSchema.Off") + != null) { + return new OutputSchemaMeta(true, null, SchemaType.NONE); + } + + String fromType = extractClassValue("io.jooby.annotation.mcp.McpOutputSchema.From"); + if (fromType != null) return new OutputSchemaMeta(false, fromType, SchemaType.FROM); + + String arrayType = extractClassValue("io.jooby.annotation.mcp.McpOutputSchema.ArrayOf"); + if (arrayType != null) return new OutputSchemaMeta(false, arrayType, SchemaType.ARRAY); + + String mapType = extractClassValue("io.jooby.annotation.mcp.McpOutputSchema.MapOf"); + if (mapType != null) return new OutputSchemaMeta(false, mapType, SchemaType.MAP); + + return new OutputSchemaMeta(false, null, SchemaType.NONE); + } + private McpAnnotation parseResourceAnnotation() { String rawAnnotations = extractAnnotationValue("io.jooby.annotation.mcp.McpResource", "annotations"); @@ -1138,4 +1282,13 @@ private record McpToolAnnotation( String readOnlyHint, String destructiveHint, String idempotentHint, String openWorldHint) {} private record McpAnnotation(String audience, String lastModified, String priority) {} + + private enum SchemaType { + NONE, + FROM, + ARRAY, + MAP + } + + private record OutputSchemaMeta(boolean isOff, String type, SchemaType schemaType) {} } diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java index 6ca075efc6..4996e5a4ee 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java @@ -188,10 +188,13 @@ public String toSourceCode(boolean kt) throws IOException { buffer.append( statement( indent(4), "private lateinit var json: io.modelcontextprotocol.json.McpJsonMapper")); + buffer.append(statement(indent(4), "private var generateOutputSchema: Boolean = false")); } else { buffer.append( statement( indent(4), "private io.modelcontextprotocol.json.McpJsonMapper json", semicolon(kt))); + buffer.append( + statement(indent(4), "private boolean generateOutputSchema = false", semicolon(kt))); } // --- capabilities() --- @@ -224,6 +227,29 @@ public String toSourceCode(boolean kt) throws IOException { } buffer.append(statement(indent(4), "}\n")); + // --- generateOutputSchema() --- + if (kt) { + buffer.append( + statement( + indent(4), + "override fun generateOutputSchema(generateOutputSchema: Boolean):" + + " io.jooby.mcp.McpService {")); + buffer.append(statement(indent(6), "this.generateOutputSchema = generateOutputSchema")); + buffer.append(statement(indent(6), "return this")); + buffer.append(statement(indent(4), "}\n")); + } else { + buffer.append(statement(indent(4), "@Override")); + buffer.append( + statement( + indent(4), + "public io.jooby.mcp.McpService generateOutputSchema(boolean generateOutputSchema)" + + " {")); + buffer.append( + statement(indent(6), "this.generateOutputSchema = generateOutputSchema", semicolon(kt))); + buffer.append(statement(indent(6), "return this", semicolon(kt))); + buffer.append(statement(indent(4), "}\n")); + } + // --- serverKey() --- var serverName = getMcpServerKey(); if (kt) { diff --git a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java index d3eb09aa25..339f535746 100644 --- a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java +++ b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java @@ -47,6 +47,7 @@ private void setup(java.util.function.Function } private io.modelcontextprotocol.json.McpJsonMapper json; + private boolean generateOutputSchema = false; @Override public void capabilities(io.modelcontextprotocol.spec.McpSchema.ServerCapabilities.Builder capabilities) { capabilities.tools(true); @@ -55,6 +56,12 @@ public void capabilities(io.modelcontextprotocol.spec.McpSchema.ServerCapabiliti capabilities.completions(); } + @Override + public io.jooby.mcp.McpService generateOutputSchema(boolean generateOutputSchema) { + this.generateOutputSchema = generateOutputSchema; + return this; + } + @Override public String serverKey() { return "example-server"; diff --git a/modules/jooby-apt/src/test/java/tests/i3830/OutputSchemaTest.java b/modules/jooby-apt/src/test/java/tests/i3830/OutputSchemaTest.java new file mode 100644 index 0000000000..d56bd7e507 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3830/OutputSchemaTest.java @@ -0,0 +1,144 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.i3830; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import io.jooby.apt.ProcessorRunner; + +public class OutputSchemaTest { + + @Test + public void outputSchemaOff() throws Exception { + new ProcessorRunner(new OutputSchemaTools()) + .withMcpCode( + source -> { + assertThat(source) + .containsIgnoringWhitespaces( + """ + private io.modelcontextprotocol.spec.McpSchema.Tool schemaOffToolSpec(com.github.victools.jsonschema.generator.SchemaGenerator schemaGenerator) { + var schema = new java.util.LinkedHashMap(); + schema.put("type", "object"); + var props = new java.util.LinkedHashMap(); + schema.put("properties", props); + var req = new java.util.ArrayList(); + schema.put("required", req); + return new io.modelcontextprotocol.spec.McpSchema.Tool("schemaOff", null, null, this.json.convertValue(schema, io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), null, null, null); + } + """); + }); + } + + @Test + public void explicitMapOfPojoAsOutputSchema() throws Exception { + new ProcessorRunner(new OutputSchemaTools()) + .withMcpCode( + source -> { + assertThat(source) + .containsIgnoringWhitespaces( + """ + private io.modelcontextprotocol.spec.McpSchema.Tool schemaMapToolSpec(com.github.victools.jsonschema.generator.SchemaGenerator schemaGenerator) { + var schema = new java.util.LinkedHashMap(); + schema.put("type", "object"); + var props = new java.util.LinkedHashMap(); + schema.put("properties", props); + var req = new java.util.ArrayList(); + schema.put("required", req); + java.util.Map schemaMapOutputSchema = null; + var schemaMapOutputSchemaNode = schemaGenerator.generateSchema(tests.i3830.Pet.class); + var schemaMapOutputSchemaMap = this.json.convertValue(schemaMapOutputSchemaNode, java.util.Map.class); + var schemaMapOutputSchemaWrapped = new java.util.LinkedHashMap(); + schemaMapOutputSchemaWrapped.put("type", "object"); + schemaMapOutputSchemaWrapped.put("additionalProperties", schemaMapOutputSchemaMap); + schemaMapOutputSchema = schemaMapOutputSchemaWrapped; + return new io.modelcontextprotocol.spec.McpSchema.Tool("schemaMap", null, null, this.json.convertValue(schema, io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), schemaMapOutputSchema, null, null); + } + """); + }); + } + + @Test + public void explicitListOfPojoAsOutputSchema() throws Exception { + new ProcessorRunner(new OutputSchemaTools()) + .withMcpCode( + source -> { + assertThat(source) + .containsIgnoringWhitespaces( + """ + private io.modelcontextprotocol.spec.McpSchema.Tool schemaListToolSpec(com.github.victools.jsonschema.generator.SchemaGenerator schemaGenerator) { + var schema = new java.util.LinkedHashMap(); + schema.put("type", "object"); + var props = new java.util.LinkedHashMap(); + schema.put("properties", props); + var req = new java.util.ArrayList(); + schema.put("required", req); + java.util.Map schemaListOutputSchema = null; + var schemaListOutputSchemaNode = schemaGenerator.generateSchema(tests.i3830.Pet.class); + var schemaListOutputSchemaMap = this.json.convertValue(schemaListOutputSchemaNode, java.util.Map.class); + var schemaListOutputSchemaWrapped = new java.util.LinkedHashMap(); + schemaListOutputSchemaWrapped.put("type", "array"); + schemaListOutputSchemaWrapped.put("items", schemaListOutputSchemaMap); + schemaListOutputSchema = schemaListOutputSchemaWrapped; + return new io.modelcontextprotocol.spec.McpSchema.Tool("schemaList", null, null, this.json.convertValue(schema, io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), schemaListOutputSchema, null, null); + } + """); + }); + } + + @Test + public void explicitPojoAsOutputSchema() throws Exception { + new ProcessorRunner(new OutputSchemaTools()) + .withMcpCode( + source -> { + assertThat(source) + .containsIgnoringWhitespaces( + """ + private io.modelcontextprotocol.spec.McpSchema.Tool explicitSchemaToolSpec(com.github.victools.jsonschema.generator.SchemaGenerator schemaGenerator) { + var schema = new java.util.LinkedHashMap(); + schema.put("type", "object"); + var props = new java.util.LinkedHashMap(); + schema.put("properties", props); + var req = new java.util.ArrayList(); + schema.put("required", req); + java.util.Map explicitSchemaOutputSchema = null; + var explicitSchemaOutputSchemaNode = schemaGenerator.generateSchema(tests.i3830.Pet.class); + var explicitSchemaOutputSchemaMap = this.json.convertValue(explicitSchemaOutputSchemaNode, java.util.Map.class); + explicitSchemaOutputSchema = explicitSchemaOutputSchemaMap; + return new io.modelcontextprotocol.spec.McpSchema.Tool("explicitSchema", null, null, this.json.convertValue(schema, io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), explicitSchemaOutputSchema, null, null); + } + """); + }); + } + + @Test + public void shouldConditionallyGenerateOutputSchema() throws Exception { + new ProcessorRunner(new OutputSchemaTools()) + .withMcpCode( + source -> { + assertThat(source) + .containsIgnoringWhitespaces( + """ + private io.modelcontextprotocol.spec.McpSchema.Tool defaultSchemaToolSpec(com.github.victools.jsonschema.generator.SchemaGenerator schemaGenerator) { + var schema = new java.util.LinkedHashMap(); + schema.put("type", "object"); + var props = new java.util.LinkedHashMap(); + schema.put("properties", props); + var req = new java.util.ArrayList(); + schema.put("required", req); + java.util.Map defaultSchemaOutputSchema = null; + if (this.generateOutputSchema) { + var defaultSchemaOutputSchemaNode = schemaGenerator.generateSchema(tests.i3830.Pet.class); + var defaultSchemaOutputSchemaMap = this.json.convertValue(defaultSchemaOutputSchemaNode, java.util.Map.class); + defaultSchemaOutputSchema = defaultSchemaOutputSchemaMap; + } + return new io.modelcontextprotocol.spec.McpSchema.Tool("defaultSchema", null, null, this.json.convertValue(schema, io.modelcontextprotocol.spec.McpSchema.JsonSchema.class), defaultSchemaOutputSchema, null, null); + } + """); + }); + } +} diff --git a/modules/jooby-apt/src/test/java/tests/i3830/OutputSchemaTools.java b/modules/jooby-apt/src/test/java/tests/i3830/OutputSchemaTools.java new file mode 100644 index 0000000000..7a4afee9b3 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3830/OutputSchemaTools.java @@ -0,0 +1,40 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.i3830; + +import io.jooby.annotation.mcp.McpOutputSchema; +import io.jooby.annotation.mcp.McpTool; + +public class OutputSchemaTools { + @McpTool + @McpOutputSchema.Off + public Pet schemaOff() { + return new Pet("dog"); + } + + @McpTool + @McpOutputSchema.From(Pet.class) + public Object explicitSchema() { + return new Pet("dog"); + } + + @McpTool + @McpOutputSchema.MapOf(Pet.class) + public Object schemaMap() { + return new Pet("dog"); + } + + @McpTool + @McpOutputSchema.ArrayOf(Pet.class) + public Object schemaList() { + return new Pet("dog"); + } + + @McpTool + public Pet defaultSchema() { + return new Pet("dog"); + } +} diff --git a/modules/jooby-apt/src/test/java/tests/i3830/Pet.java b/modules/jooby-apt/src/test/java/tests/i3830/Pet.java new file mode 100644 index 0000000000..573f93ce83 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3830/Pet.java @@ -0,0 +1,8 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.i3830; + +public record Pet(String name) {} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpOutputSchema.java b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpOutputSchema.java new file mode 100644 index 0000000000..0a01e03897 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpOutputSchema.java @@ -0,0 +1,65 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation.mcp; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Controls the generation of the JSON output schema for this tool. + * + *

By default, output schema generation is controlled by a global flag on the MCP Module (which + * defaults to false to save LLM context window tokens). Applying any of the annotations in this + * group to a tool method will explicitly override the global flag. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface McpOutputSchema { + + /** + * Forces the generation of an output schema based on the provided class, regardless of the global + * module configuration. + * + *

This is especially useful when the method's actual return type is generic (e.g., {@code + * Object} or {@code Response}) due to type erasure, but you want the LLM to know the exact JSON + * shape. + */ + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + @interface From { + Class value(); + } + + /** + * Forces the generation of an array output schema based on the provided class, regardless of the + * global module configuration. + */ + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + @interface ArrayOf { + Class value(); + } + + /** + * Forces the generation of a Map output schema (String keys to the provided class values), + * regardless of the global module configuration. + */ + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + @interface MapOf { + Class value(); + } + + /** + * Explicitly disables output schema generation for this specific tool, even if the global module + * flag is set to true. + */ + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + @interface Off {} +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java index 1b546e456c..cc98c1e1cc 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java @@ -154,6 +154,8 @@ public class McpModule implements Extension { private McpInvoker invoker; + private Boolean generateOutputSchema = null; + /** * Creates a new MCP module initialized with the provided generated services. * @@ -208,10 +210,26 @@ public McpModule invoker(@NonNull McpInvoker invoker) { return this; } + /** + * Enabled/disables the generation of the output schema. It is automatically loaded from + * mcp.generateOutputSchema which by default is false. + * + * @param generateOutputSchema true to enable the generation of the output schema. + * @return This module instance for method chaining. + */ + public McpModule generateOutputSchema(boolean generateOutputSchema) { + this.generateOutputSchema = generateOutputSchema; + return this; + } + @Override public void install(@NonNull Jooby app) { var services = app.getServices(); var mcpJsonMapper = services.require(McpJsonMapper.class); + var globalGenerateOutputSchema = + app.getConfig().hasPath("mcp.generateOutputSchema") + ? app.getConfig().getBoolean("mcp.generateOutputSchema") + : Optional.ofNullable(this.generateOutputSchema).orElse(Boolean.FALSE); // invoker McpInvoker firstInvoker = new DefaultMcpInvoker(app); if (this.invoker != null) { @@ -221,8 +239,14 @@ public void install(@NonNull Jooby app) { // Group services by server var mcpServiceMap = new HashMap>(); for (var mcpService : mcpServices) { - var serverKey = Optional.ofNullable(mcpService.serverKey()).orElse("default"); - mcpServiceMap.computeIfAbsent(serverKey, k -> new ArrayList<>()).add(mcpService); + var localGenerateOutputSchemaPath = + MODULE_CONFIG_PREFIX + "." + mcpService.serverKey() + ".generateOutputSchema"; + var localGenerateOutputSchema = + app.getConfig().hasPath(localGenerateOutputSchemaPath) + ? app.getConfig().getBoolean(localGenerateOutputSchemaPath) + : globalGenerateOutputSchema; + mcpService.generateOutputSchema(localGenerateOutputSchema); + mcpServiceMap.computeIfAbsent(mcpService.serverKey(), k -> new ArrayList<>()).add(mcpService); } // Boot everything for (var serverEntry : mcpServiceMap.entrySet()) { diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java index aac6eb5b8e..f8449f4c06 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java @@ -88,4 +88,6 @@ List statelessCompletion * @return The target server key. */ String serverKey(); + + McpService generateOutputSchema(boolean generateOutputSchema); } diff --git a/tests/src/test/java/io/jooby/i3830/UserToolsTest.java b/tests/src/test/java/io/jooby/i3830/UserToolsTest.java index 2edeaa799c..9d06b2bf95 100644 --- a/tests/src/test/java/io/jooby/i3830/UserToolsTest.java +++ b/tests/src/test/java/io/jooby/i3830/UserToolsTest.java @@ -29,6 +29,7 @@ private void setupMcpApp(Jooby app, Extension... extensions) { } app.install( new McpModule(new UserToolsMcp_()) + .generateOutputSchema(true) .transport(McpModule.Transport.STATELESS_STREAMABLE_HTTP)); } From bb61e3dff75f3a9e85e2ddfd14aff501c5b6506d Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 30 Mar 2026 20:22:08 -0300 Subject: [PATCH 36/37] - document new module!!! --- docs/asciidoc/modules/mcp.adoc | 356 +++++++++++++++++++++++++++++ docs/asciidoc/modules/modules.adoc | 1 + pom.xml | 6 + 3 files changed, 363 insertions(+) create mode 100644 docs/asciidoc/modules/mcp.adoc diff --git a/docs/asciidoc/modules/mcp.adoc b/docs/asciidoc/modules/mcp.adoc new file mode 100644 index 0000000000..a193f1705c --- /dev/null +++ b/docs/asciidoc/modules/mcp.adoc @@ -0,0 +1,356 @@ +== MCP + +https://modelcontextprotocol.io/[Model Context Protocol (MCP)] module for Jooby. + +The MCP module provides seamless integration with the Model Context Protocol, allowing your application to act as a standardized AI context server. It automatically bridges your Java/Kotlin methods with LLM clients by exposing them as Tools, Resources, and Prompts. + +=== Usage + +1) Add the dependencies (Jooby MCP, Jackson, and the APT processor): + +[dependency, artifactId="jooby-jackson3:Jackson Module, jooby-mcp-jackson3:MCP Jackson Module, jooby-mcp:MCP Module"] +. + +[NOTE] +==== +You must configure the Jooby Annotation Processor (APT) in your build. The MCP module relies on APT to generate high-performance routing code with zero reflection overhead. +==== + +2) Define a service and expose capabilities via annotations: + +.Java +[source, java, role="primary"] +---- +import io.jooby.annotation.mcp.McpTool; + +public class CalculatorService { + + @McpTool(description = "Adds two numbers together") + public int add(int a, int b) { + return a + b; + } +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.annotation.mcp.McpTool + +class CalculatorService { + + @McpTool(description = "Adds two numbers together") + fun add(a: Int, b: Int): Int { + return a + b + } +} +---- + +3) Install the module using Jackson and the generated service: + +.Java +[source, java, role="primary"] +---- +import io.jooby.jackson3.Jackson3Module; +import io.jooby.mcp.jackson3.McpJackson3Module; +import io.jooby.mcp.McpModule; + +{ + install(new Jackson3Module()); <1> + + install(new McpJackson3Module()); <2> + + install(new McpModule(new CalculatorServiceMcp_())); <3> +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.jackson3.Jackson3Module +import io.jooby.mcp.jackson3.McpJackson3Module +import io.jooby.mcp.McpModule + +{ + install(Jackson3Module()) <1> + + install(McpJackson3Module()) <2> + + install(McpModule(CalculatorServiceMcp_())) <3> +} +---- + +<1> Install JSON support (Jackson is required for MCP JSON-RPC serialization). For Jackson 2, use `JacksonModule()` instead. +<2> Install MCP JSON support. For Jackson 2, use `McpJackson2Module()` instead. +<3> Install the MCP module with the APT-generated `McpService` dispatcher. The generated class always ends with the `Mcp_` suffix. + +=== Core Capabilities + +The module uses annotations to expose application logic to AI clients: + +- `@McpTool`: Exposes a method as an executable tool. The module automatically generates JSON schemas for the parameters. +- `@McpPrompt`: Exposes a method as a reusable prompt template. +- `@McpResource` / `@McpResourceTemplate`: Exposes static or dynamic data as an MCP resource (e.g., `file://config`). +- `@McpCompletion`: Handles auto-completion logic for prompts and resources. + +=== Schema Descriptions & Javadoc + +Rich descriptions are highly recommended for LLM usage so the model understands exactly what a tool, prompt, or resource does. You can provide these descriptions directly in the MCP annotations (e.g., `@McpTool(description = "...")`). + +However, if you omit them, the Jooby Annotation Processor will automatically extract descriptions from your standard Javadoc comments, including method descriptions and `@param` tags. + +.Javadoc Extraction Example +[source, java] +---- +import io.jooby.annotation.mcp.McpTool; + +public class WeatherService { + + /** + * Retrieves the current weather forecast for a given location. + * + * @param location The city and state, e.g., "San Francisco, CA" + * @param units The temperature unit to use (celsius or fahrenheit) + */ + @McpTool + public WeatherForecast getWeather(String location, String units) { + // ... + } +} +---- + +In the example above, the LLM will automatically receive the method's Javadoc summary as the tool description, and the `@param` comments as the descriptions for the `location` and `units` JSON schema properties. + +=== Transports + +By default, the MCP module starts a single server using the `STREAMABLE_HTTP` transport. You can easily switch to other supported transports such as `SSE` (Server-Sent Events), `WEBSOCKET`, or `STATELESS_STREAMABLE_HTTP`. + +.Changing Transport +[source, java] +---- +import io.jooby.mcp.McpModule.Transport; + +{ + install(new McpModule(new CalculatorServiceMcp_()) + .transport(Transport.WEBSOCKET)); +} +---- + +=== Output Schema Generation + +By default, the framework does *not* generate JSON output schemas for tools in order to save LLM context window tokens. You can enable it globally on the module, or override it per-method using the `@McpOutputSchema` annotation. + +.Programmatic Global Configuration +[source, java] +---- +{ + // Enable output schema generation for all tools by default + install(new McpModule(new CalculatorServiceMcp_()) + .generateOutputSchema(true)); +} +---- + +Alternatively, you can control output schema generation using your application configuration properties. Configuration properties always take precedence over the programmatic setup, and allow you to configure behavior per-server. + +.application.conf +[source, properties] +---- +# Global fallback for all servers +mcp.generateOutputSchema = true + +# Per-server override (takes precedence over the global flag) +mcp.calculator.generateOutputSchema = false +---- + +You can explicitly override the global/config flag and bypass Java type erasure using nested `@McpOutputSchema` annotations: + +.Per-Method Override +[source, java] +---- +import io.jooby.annotation.mcp.McpTool; +import io.jooby.annotation.mcp.McpOutputSchema; + +public class UserService { + + @McpTool + @McpOutputSchema.ArrayOf(User.class) <1> + public List findUsers(String query) { + // ... + } + + @McpTool + @McpOutputSchema.Off <2> + public HugeDataset getBigData() { + // ... + } +} +---- + +<1> Forces array schema generation for `User`, overriding generic `Object` erasure and the global/config flag. +<2> Explicitly disables schema generation for this specific tool. + +=== Custom Invokers & Telemetry + +You can inject custom logic (like SLF4J MDC context propagation, tracing, or custom error handling) around every tool, prompt, or resource execution by providing an `McpInvoker`. + +[NOTE] +==== +Invokers are chained. You can register multiple invokers and they will wrap the execution in the order they were added: +`install(new McpModule(...).invoker(new LoggingInvoker()).invoker(new SecurityInvoker()));` +==== + +.MDC Invoker Example +[source, java] +---- +import io.jooby.mcp.McpInvoker; +import io.jooby.mcp.McpOperation; +import org.slf4j.MDC; + +public class MdcMcpInvoker implements McpInvoker { + @Override + public R invoke(McpOperation operation, SneakyThrows.Supplier action) { + try { + MDC.put("mcp.id", operation.id()); <1> + MDC.put("mcp.class", operation.className()); + MDC.put("mcp.method", operation.methodName()); + return action.get(); <2> + } finally { + MDC.remove("mcp.id"); + MDC.remove("mcp.class"); + MDC.remove("mcp.method"); + } + } +} + +{ + install(new McpModule(new CalculatorServiceMcp_()) + .invoker(new MdcMcpInvoker())); <3> +} +---- + +<1> Extract rich contextual data from the `McpOperation` record. +<2> Proceed with the execution chain. +<3> Register the invoker. Jooby will safely map any business exceptions thrown by your action into valid MCP JSON-RPC errors. + +=== Multiple Servers + +You can run multiple, completely isolated MCP server instances within the same Jooby application by utilizing the `@McpServer("serverName")` annotation on your service classes. + +When bootstrapping multiple servers, you *must* provide configuration for each server in your `application.conf`. + +.application.conf +[source, properties] +---- +mcp.default.name = "default-mcp-server" +mcp.default.version = "1.0.0" + +mcp.calculator.name = "calculator-mcp-server" +mcp.calculator.version = "1.0.0" +mcp.calculator.transport = "sse" +mcp.calculator.mcpEndpoint = "/mcp/calculator/sse" +---- + +.Java Bootstrap +[source, java] +---- +{ + // Bootstraps services each on their corresponding service base based on the @McpServer mappings + install(new McpModule( + new DefaultServiceMcp_(), + new CalculatorServiceMcp_() + )); +} +---- + +=== Debugging & Testing (MCP Inspector) + +When building an MCP server, it is highly recommended to test your tools, prompts, and resources locally before connecting them to a real LLM client. + +The `McpInspectorModule` provides a built-in, interactive web UI that acts as a dummy LLM client. It allows you to manually trigger tools, view generated output schemas, inspect resources, and debug JSON-RPC payloads in real-time. + +1) Add the inspector dependency (typically as a `test` or `development` dependency): + +[dependency, artifactId="jooby-mcp-inspector:MCP Inspector"] +. + +2) Install the module, ensuring it is only active during development: + +.Java Bootstrap +[source, java, role="primary"] +---- +import io.jooby.mcp.McpModule; +import io.jooby.mcp.inspector.McpInspectorModule; + +{ + install(new McpModule( + new DefaultServiceMcp_(), + new CalculatorServiceMcp_() + )); + + // Only enable the inspector UI in the 'dev' environment + if (getEnvironment().isActive("dev")) { + install(new McpInspectorModule()); + } +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.mcp.McpModule +import io.jooby.mcp.inspector.McpInspectorModule + +{ + install(McpModule( + DefaultServiceMcp_(), + CalculatorServiceMcp_() + )) + + // Only enable the inspector UI in the 'dev' environment + if (environment.isActive("dev")) { + install(McpInspectorModule()) + } +} +---- + +Once the application starts, open your browser and navigate to the default inspector route: `http://localhost:8080/mcp-inspector`. + +==== Inspector Configuration + +You can customize the behavior and mounting point of the Inspector UI using its programmatic builder methods: + +.Programmatic Setup +[source, java, role="primary"] +---- +{ + if (getEnvironment().isActive("dev")) { + install(new McpInspectorModule() + .path("/debug/mcp") <1> + .defaultServer("calculator-mcp-server") <2> + .autoConnect(true) <3> + ); + } +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +{ + if (environment.isActive("dev")) { + install(McpInspectorModule() + .path("/debug/mcp") <1> + .defaultServer("calculator-mcp-server") <2> + .autoConnect(true) <3> + ) + } +} +---- + +<1> Changes the base path for the Inspector UI (defaults to `/mcp-inspector`). +<2> Automatically selects a specific named server in the UI dropdown when dealing with multiple MCP servers. +<3> Automatically connects to the selected server as soon as the page loads. + +=== Special Thanks + +A special thanks to https://github.com/kliushnichenko[kliushnichenko]. This MCP module was heavily inspired by and based upon their foundational work and contributions to the ecosystem. diff --git a/docs/asciidoc/modules/modules.adoc b/docs/asciidoc/modules/modules.adoc index 3458e56617..e9c396b73b 100644 --- a/docs/asciidoc/modules/modules.adoc +++ b/docs/asciidoc/modules/modules.adoc @@ -8,6 +8,7 @@ Modules are distributed as separate dependencies. Below is the catalog of offici ==== AI * link:{uiVersion}/modules/langchain4j[LangChain4j]: Supercharge your Java application with the power of LLMs. + * link:{uiVersion}/modules/mcp[MCP]: The MCP module provides seamless integration with the Model Context Protocol. ==== Cloud * link:{uiVersion}/modules/awssdkv2[AWS-SDK v2]: Amazon Web Service module SDK 2. diff --git a/pom.xml b/pom.xml index d5ce0cadef..c7244e22e5 100644 --- a/pom.xml +++ b/pom.xml @@ -614,6 +614,12 @@ ${jooby.version} + + io.jooby + jooby-mcp-inspector + ${jooby.version} + + com.github.ben-manes.caffeine caffeine From 6490513e6354fd47f6f5b3c3d5d4d3cacb278dda Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 30 Mar 2026 20:33:49 -0300 Subject: [PATCH 37/37] doc: add note on main features --- docs/asciidoc/index.adoc | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/asciidoc/index.adoc b/docs/asciidoc/index.adoc index 518d583ee1..d2d91d82ab 100644 --- a/docs/asciidoc/index.adoc +++ b/docs/asciidoc/index.adoc @@ -59,6 +59,7 @@ fun main(args: Array) { * **Execution Options:** Choose between <>. * **Reactive Ready:** Support for <> (CompletableFuture, RxJava, Reactor, Mutiny, and Kotlin Coroutines). * **Server Choice:** Run on https://www.eclipse.org/jetty[Jetty], https://netty.io[Netty], https://vertx.io[Vert.x], or http://undertow.io[Undertow]. +* **AI Ready:** Seamlessly expose your application's data and functions to Large Language Models (LLMs) using the first-class link:modules/mcp[Model Context Protocol (MCP)] module. * **Extensible:** Scale to a full-stack framework using extensions and link:modules[modules]. [TIP]