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] 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/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/modules/jooby-apt/pom.xml b/modules/jooby-apt/pom.xml index 4f98b591cf..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 @@ -25,6 +31,20 @@ test + + io.jooby + jooby-mcp + ${jooby.version} + test + + + + com.github.victools + jsonschema-generator + 5.0.0 + test + + io.jooby jooby-jackson3 @@ -44,7 +64,6 @@ test - com.google.testing.compile compile-testing @@ -58,6 +77,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..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 @@ -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; @@ -16,16 +16,12 @@ 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; +import javax.tools.*; import io.jooby.internal.apt.*; @@ -38,7 +34,7 @@ ROUTER_SUFFIX, SKIP_ATTRIBUTE_ANNOTATIONS }) -@SupportedSourceVersion(SourceVersion.RELEASE_17) +@SupportedSourceVersion(SourceVersion.RELEASE_21) public class JoobyProcessor extends AbstractProcessor { /** Available options. */ public interface Options { @@ -129,71 +125,54 @@ 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()) { + // Discover all unique Controller classes + var controllers = findControllers(annotations, roundEnv); + + // Factory Pattern: Build specific routers for each class based on method annotations + List> activeRouters = new ArrayList<>(); + for (var controller : controllers) { + if (controller.getModifiers().contains(Modifier.ABSTRACT)) continue; + + var restRouter = RestRouter.parse(context, controller); + if (!restRouter.isEmpty()) { + activeRouters.add(restRouter); + } + + var jsonRpcRouter = JsonRpcRouter.parse(context, controller); + if (!jsonRpcRouter.isEmpty()) { + activeRouters.add(jsonRpcRouter); + } + + var mcpRouter = McpRouter.parse(context, controller); + if (!mcpRouter.isEmpty()) { + activeRouters.add(mcpRouter); + } + + var trpcRouter = TrpcRouter.parse(context, controller); + if (!trpcRouter.isEmpty()) { + activeRouters.add(trpcRouter); + } + } + + verifyBeanValidationDependency(activeRouters); + + // Generate Code Iteratively! + for (var 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()); - } - } - - // 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()); - } - } + var sourceCode = router.toSourceCode(router.isKt()); + var sourceLocation = router.getGeneratedFilename(); + var 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) { throw new RuntimeException("Unable to generate: " + router.getTargetType(), cause); } @@ -207,6 +186,22 @@ 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 + && !typeElement.getModifiers().contains(Modifier.ABSTRACT)) { + controllers.add(typeElement); + } else if (element instanceof ExecutableElement method) { + controllers.add((TypeElement) method.getEnclosingElement()); + } + } + } + return controllers; + } + private void writeSource( boolean isKt, String className, @@ -265,142 +260,22 @@ 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.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; } @@ -426,37 +301,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. * @@ -469,8 +313,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/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/HttpMethod.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java index f7d2026edc..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 @@ -27,14 +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")); + PUT; private final List annotations; @@ -44,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/JsonRpcRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRoute.java new file mode 100644 index 0000000000..d84288baaf --- /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(JsonRpcRouter 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)); + + var 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 parameterName = 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 ", + parameterName, + " = if (reader.nextIsNull(", + string(parameterName), + ")) null else reader.", + readName, + "(", + string(parameterName), + ")")); + } else { + statements.add( + statement( + indent(baseIndent), + var(kt), + parameterName, + " = reader.nextIsNull(", + string(parameterName), + ") ? null : reader.", + readName, + "(", + string(parameterName), + ")", + semicolon(kt))); + } + } else { + statements.add( + statement( + indent(baseIndent), + var(kt), + parameterName, + " = reader.", + readName, + "(", + string(parameterName), + ")", + semicolon(kt))); + } + arguments.accept(parameterName); + break; + default: + if (kt) { + statements.add( + statement( + indent(baseIndent), + "val ", + parameterName, + "Decoder: ", + decoderInterface, + "<", + type, + "> = parser.decoder(", + parameter.getType().toSourceCode(kt), + ")", + semicolon(kt))); + if (isNullable) { + statements.add( + statement( + indent(baseIndent), + "val ", + parameterName, + " = if (reader.nextIsNull(", + string(parameterName), + ")) null else reader.nextObject(", + string(parameterName), + ", ", + parameterName, + "Decoder)")); + } else { + statements.add( + statement( + indent(baseIndent), + "val ", + parameterName, + " = reader.nextObject(", + string(parameterName), + ", ", + parameterName, + "Decoder)", + semicolon(kt))); + } + } else { + statements.add( + statement( + indent(baseIndent), + decoderInterface, + "<", + type, + "> ", + parameterName, + "Decoder = parser.decoder(", + parameter.getType().toSourceCode(kt), + ")", + semicolon(kt))); + if (isNullable) { + statements.add( + statement( + indent(baseIndent), + parameter.getType().toString(), + " ", + parameterName, + " = reader.nextIsNull(", + string(parameterName), + ") ? null : reader.nextObject(", + string(parameterName), + ", ", + parameterName, + "Decoder)", + semicolon(kt))); + } else { + statements.add( + statement( + indent(baseIndent), + parameter.getType().toString(), + " ", + parameterName, + " = reader.nextObject(", + string(parameterName), + ", ", + parameterName, + "Decoder)", + semicolon(kt))); + } + } + arguments.accept(parameterName); + 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..36dce8da9a --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/JsonRpcRouter.java @@ -0,0 +1,211 @@ +/* + * 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) { + var router = new JsonRpcRouter(context, controller); + var classAnnotation = + AnnotationSupport.findAnnotationByName(controller, "io.jooby.annotation.JsonRpc"); + + var explicitlyAnnotated = new ArrayList(); + var 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)) { + var 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) { + var route = new JsonRpcRoute(router, method); + router.routes.put(route.getMethodName(), route); + } + } else if (classAnnotation != null) { + for (var method : allPublicMethods) { + var route = new JsonRpcRoute(router, method); + router.routes.put(route.getMethodName(), route); + } + } + return router; + } + + @Override + public String getGeneratedType() { + return context.generateRouterName(getTargetType().getQualifiedName() + "Rpc"); + } + + private 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 toSourceCode(boolean kt) throws IOException { + var generateTypeName = getTargetType().getSimpleName().toString(); + var generatedClass = getGeneratedType().substring(getGeneratedType().lastIndexOf('.') + 1); + var namespace = getJsonRpcNamespace(); + + var template = getTemplate(kt); + 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 (var route : getRoutes()) { + var routeName = route.getJsonRpcMethodName(); + fullMethods.add(namespace.isEmpty() ? routeName : namespace + "." + routeName); + } + + var methodListString = + fullMethods.stream().map(m -> "\"" + m + "\"").collect(Collectors.joining(", ")); + + if (kt) { + 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? {")); + 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(statement(indent(8), string(fullMethods.get(i)), " -> {")); + getRoutes().get(i).generateJsonRpcDispatchCase(true).forEach(buffer::append); + buffer.append(statement(indent(8), "}")); + } + + buffer.append( + statement( + indent(8), + "else -> throw" + + " 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(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 {")); + buffer.append(statement(indent(6), "var c = factory.apply(ctx);")); + buffer.append(statement(indent(6), "var method = req.getMethod();")); + buffer.append( + statement( + 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++) { + buffer.append(statement(indent(8), "case ", string(fullMethods.get(i)), ": {")); + getRoutes().get(i).generateJsonRpcDispatchCase(false).forEach(buffer::append); + buffer.append(statement(indent(8), "}")); + } + + 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," + + " ", + string("Method not found:"), + " + method);")); + buffer.append(statement(indent(6), "}")); + buffer.append(statement(indent(4), "}")); + } + + 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..1fc1e0b963 --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java @@ -0,0 +1,1294 @@ +/* + * 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 static io.jooby.internal.apt.CodeBlock.string; + +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; + private boolean isMcpPrompt = false; + private boolean isMcpResource = false; + private boolean isMcpResourceTemplate = false; + private boolean isMcpCompletion = false; + + public McpRoute(McpRouter router, ExecutableElement method) { + super(router, method); + checkMcpAnnotations(); + } + + private void checkMcpAnnotations() { + if (AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.mcp.McpTool") + != null) { + this.isMcpTool = true; + } + if (AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.mcp.McpPrompt") + != null) { + this.isMcpPrompt = true; + } + if (AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.mcp.McpCompletion") + != null) { + this.isMcpCompletion = true; + } + + var resourceAnno = + AnnotationSupport.findAnnotationByName(this.method, "io.jooby.annotation.mcp.McpResource"); + if (resourceAnno != null) { + String uri = + AnnotationSupport.findAnnotationValue(resourceAnno, "uri"::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 null; + return AnnotationSupport.findAnnotationValue(annotation, attribute::equals).stream() + .findFirst() + .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 = 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"; + + // --- NESTED ANNOTATION EXTRACTION --- + String annotationsArg = "null"; + var annotation = parseResourceAnnotation(); + + var isTemplate = isMcpResourceTemplate(); + var specType = isTemplate ? "ResourceTemplate" : "Resource"; + + 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, "")); + buffer.add( + statement( + indent(6), + "val annotations = io.modelcontextprotocol.spec.McpSchema.Annotations(audience, ", + annotation.priority, + ", ", + annotation.lastModified, + ")")); + } + + 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")); + + } 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"; + + 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, + ")", + 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(4), "}\n")); + } + return buffer; + } + + 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); + + 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 (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), + "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), + ", ", + 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; + } + + 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); + + if (kt) { + buffer.add( + statement( + indent(4), + "private fun ", + getMethodName(), + "ToolSpec(schemaGenerator:" + + " com.github.victools.jsonschema.generator.SchemaGenerator):" + + " io.modelcontextprotocol.spec.McpSchema.Tool {")); + 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 = 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(com.github.victools.jsonschema.generator.SchemaGenerator" + + " schemaGenerator) {")); + buffer.add( + statement( + indent(6), + "var schema = new java.util.LinkedHashMap()", + semicolon(kt))); + buffer.add( + statement( + indent(6), + "schema.put(", + string("type"), + ", ", + string("object"), + ")", + semicolon(kt))); + buffer.add( + statement( + indent(6), + "var props = new java.util.LinkedHashMap()", + semicolon(kt))); + buffer.add( + 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 --- + 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.put(", 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.put(", + string(mcpName), + ", schema_", + mcpName, + ")", + semicolon(kt))); + + if (!param.isNullable(kt)) { + buffer.add(statement(indent(6), "req.add(", string(mcpName), ")", semicolon(kt))); + } + } + } + + // --- OUTPUT SCHEMA GENERATION (RUNTIME AWARE) --- + var outMeta = parseOutputSchemaMeta(); + boolean isEligible = hasOutputSchema(); + + String outputSchemaArg = "null"; + + 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(", + targetTypeStr, + "::class.java)")); + buffer.add( + statement( + ind, + "val ", + outputSchemaArg, + "Map = this.json.convertValue(", + outputSchemaArg, + "Node, java.util.Map::class.java) as java.util.Map")); + } else { + buffer.add( + statement( + ind, + "var ", + outputSchemaArg, + "Node = schemaGenerator.generateSchema(", + targetTypeStr, + ".class)", + semicolon(kt))); + buffer.add( + statement( + ind, + "var ", + outputSchemaArg, + "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 --- + 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), + ", this.json.convertValue(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), + ", this.json.convertValue(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 = ""; + 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 List.of(); + } + + List buffer = new ArrayList<>(); + String handlerName = getMethodName(); + + if (kt) { + buffer.add( + statement( + indent(4), + "private fun ", + handlerName, + "(exchange: io.modelcontextprotocol.server.McpSyncServerExchange?, transportContext:" + + " io.modelcontextprotocol.common.McpTransportContext, req:" + + " io.modelcontextprotocol.spec.McpSchema.", + reqType, + "): io.modelcontextprotocol.spec.McpSchema.", + resType, + " {")); + + buffer.add( + statement(indent(6), "val ctx = transportContext.get(\"CTX\") as? io.jooby.Context")); + } else { + buffer.add( + statement( + indent(4), + "private io.modelcontextprotocol.spec.McpSchema.", + resType, + " ", + 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) 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.mcp.McpResource", "uri"); + 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 (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") + || 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))); + 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), + ")")); + } + + 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( + 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))); + } + + 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"; + }; + + buffer.add( + statement( + indent(6), + "var ", + javaName, + " = ", + isNullable ? "(raw_" + javaName + " == null) ? null : " : "", + "((Number) raw_", + javaName, + ").", + primitiveName, + "Value()", + semicolon(kt))); + + } else if (type.equals("java.lang.String") || type.equals("String")) { + buffer.add( + statement( + indent(6), + "var ", + javaName, + " = raw_", + javaName, + " != null ? raw_", + 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( + indent(6), "var ", javaName, " = (", type, ") raw_", javaName, semicolon(kt))); + } + } + } + + var methodCall = "c." + getMethodName() + "(" + String.join(", ", javaParamNames) + ")"; + + String toMethodPrefix = (isMcpResource() || isMcpResourceTemplate()) ? "req.uri(), " : ""; + + // 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))); + if (kt) { + buffer.add( + 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, + "(", + toMethodPrefix, + "null", + toMethodSuffix, + ")", + 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, + "(", + toMethodPrefix, + "result", + toMethodSuffix, + ")")); + } 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, + "(", + toMethodPrefix, + "result", + toMethodSuffix, + ")", + semicolon(kt))); + } + } + buffer.add(statement(indent(4), "}", System.lineSeparator())); + + 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 = + 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; + } + + 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"); + + boolean hasAnnotations = rawAnnotations != null && 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 McpToolAnnotation parseToolAnnotation() { + String rawAnnotations = + extractAnnotationValue("io.jooby.annotation.mcp.McpTool", "annotations"); + + if (rawAnnotations == null || rawAnnotations.isEmpty()) { + return null; + } + + // 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) {} + + 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 new file mode 100644 index 0000000000..4996e5a4ee --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java @@ -0,0 +1,878 @@ +/* + * 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.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); + 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() + "Mcp"); + } + + private String getMcpServerKey() { + var annotation = + AnnotationSupport.findAnnotationByName(clazz, "io.jooby.annotation.mcp.McpServer"); + if (annotation != null) { + return AnnotationSupport.findAnnotationValue(annotation, VALUE).stream() + .findFirst() + .orElse("default"); + } + 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 (var route : getRoutes()) { + if (route.isMcpPrompt()) { + 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(); + } + if (ref.equals(name)) { + return route.getMethodName(); + } + } else if (route.isMcpResource() || 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("") + : ""; + if (ref.equals(uri)) { + return route.getMethodName(); + } + } + } + return "mcpTarget" + Math.abs(ref.hashCode()); + } + + @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(); + + var template = getTemplate(kt); + 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(); + var completionGroups = new java.util.LinkedHashMap>(); + for (var route : completionRoutes) { + var annotation = + AnnotationSupport.findAnnotationByName( + route.getMethod(), "io.jooby.annotation.mcp.McpCompletion"); + String ref = + AnnotationSupport.findAnnotationValue(annotation, "value"::equals).stream() + .findFirst() + .orElse(null); + if (ref == null || ref.isEmpty()) { + ref = + AnnotationSupport.findAnnotationValue(annotation, "ref"::equals).stream() + .findFirst() + .orElse(""); + } + 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( + 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() --- + 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))); + } + if (!completionGroups.isEmpty()) { + buffer.append(statement(indent(6), "capabilities.completions()", semicolon(kt))); + } + 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) { + 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 serverKey() {")); + buffer.append(statement(indent(6), "return ", string(serverName), semicolon(kt))); + } + buffer.append(statement(indent(4), "}\n")); + + // --- completions() --- + if (kt) { + buffer.append( + statement( + indent(4), + "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), + "val completions =" + + " mutableListOf()")); + } else { + buffer.append(statement(indent(4), "@Override")); + buffer.append( + statement( + indent(4), + "public" + + " java.util.List" + + " 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), + "var completions = new" + + " java.util.ArrayList()", + semicolon(kt))); + } + + for (var ref : allCompletionRefs) { + var isResource = ref.contains("://"); + var refObj = + isResource + ? "io.modelcontextprotocol.spec.McpSchema.ResourceReference" + : "io.modelcontextprotocol.spec.McpSchema.PromptReference"; + + String lambda; + if (completionGroups.containsKey(ref)) { + 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(" + + operationArg + + ") { this." + + handlerName + + "(exchange, exchange.transportContext(), req) } }" + : "(exchange, req) -> invoker.invoke(" + + operationArg + + ", () -> this." + + handlerName + + "(exchange, exchange.transportContext(), req))"; + } else { + 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( + statement( + indent(6), + "completions.add(io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(", + refObj, + "(", + string(ref), + "), ", + lambda, + "))")); + } else { + buffer.append( + statement( + indent(6), + "completions.add(new" + + " io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new" + + " ", + refObj, + "(", + string(ref), + "), ", + lambda, + "))", + semicolon(kt))); + } + } + buffer.append(statement(indent(6), "return completions", semicolon(kt))); + buffer.append(statement(indent(4), "}\n")); + + // --- statelessCompletions() --- + if (kt) { + buffer.append( + statement( + indent(4), + "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), + "val completions =" + + " mutableListOf()")); + } else { + buffer.append(statement(indent(4), "@Override")); + buffer.append( + statement( + indent(4), + "public" + + " java.util.List" + + " 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), + "var completions = new" + + " java.util.ArrayList()", + semicolon(kt))); + } + + for (var ref : allCompletionRefs) { + var isResource = ref.contains("://"); + var refObj = + isResource + ? "io.modelcontextprotocol.spec.McpSchema.ResourceReference" + : "io.modelcontextprotocol.spec.McpSchema.PromptReference"; + + String lambda; + if (completionGroups.containsKey(ref)) { + 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(" + + operationArg + + ") { this." + + handlerName + + "(null, ctx, req) } }" + : "(ctx, req) -> invoker.invoke(" + + operationArg + + ", () -> this." + + handlerName + + "(null, ctx, req))"; + } else { + 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( + statement( + indent(6), + "completions.add(io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification(", + refObj, + "(", + string(ref), + "), ", + lambda, + "))")); + } else { + buffer.append( + statement( + indent(6), + "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")); + + // --- install() methods --- + String[] serverTypes = { + "io.modelcontextprotocol.server.McpSyncServer", + "io.modelcontextprotocol.server.McpStatelessSyncServer" + }; + + 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.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( + statement( + indent(6), + "val schemaGenerator =" + + " app.require(com.github.victools.jsonschema.generator.SchemaGenerator::class.java)")); + } + } 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.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( + statement( + indent(6), + "var schemaGenerator =" + + " app.require(com.github.victools.jsonschema.generator.SchemaGenerator.class)", + semicolon(kt))); + } + } + + buffer.append(System.lineSeparator()); + + boolean isStateless = serverType.contains("Stateless"); + String featuresClass = isStateless ? "McpStatelessServerFeatures" : "McpServerFeatures"; + + 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.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) + + ")"; + + var lambda = + kt + ? (isStateless + ? "{ ctx, req -> invoker.invoke(" + + operationArg + + ") { this." + + methodName + + "(null, ctx, req) } }" + : "{ exchange, req -> invoker.invoke(" + + operationArg + + ") { this." + + methodName + + "(exchange, exchange.transportContext(), req) } }") + : (isStateless + ? "(ctx, req) -> invoker.invoke(" + + operationArg + + ", () -> this." + + methodName + + "(null, ctx, req))" + : "(exchange, req) -> invoker.invoke(" + + operationArg + + ", () -> this." + + methodName + + "(exchange, exchange.transportContext(), req))"); + + if (route.isMcpTool()) { + var defArgs = "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())); + } + + 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"; + var routes = entry.getValue(); + + if (kt) { + buffer.append( + statement( + indent(4), + "private fun ", + handlerName, + "(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 = 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() ?: \"\"")); + 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.common.McpTransportContext transportContext," + + " io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) {")); + 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( + 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 (var route : routes) { + String targetArgName = null; + var invokeArgs = new java.util.ArrayList(); + + 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")) { + invokeArgs.add("exchange"); + } else if (type.equals("io.modelcontextprotocol.common.McpTransportContext")) { + invokeArgs.add("transportContext"); + } 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), "}", System.lineSeparator())); + } + + 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)); + } + + 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/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 6e8038a90d..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 @@ -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); @@ -41,6 +41,35 @@ public String getName() { return parameter.getSimpleName().toString(); } + public String getMcpName() { + var annotation = annotations.get("io.jooby.annotation.mcp.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 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/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 97024e3337..0000000000 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java +++ /dev/null @@ -1,1246 +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 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, 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 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, this, it)).toList(); - this.hasBeanValidation = parameters.stream().anyMatch(MvcParameter::isRequireBeanValidation); - this.returnType = - new TypeDefinition( - context.getProcessingEnvironment().getTypeUtils(), method.getReturnType()); - this.suspendFun = route.suspendFun; - 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; - } - - 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, 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(", - 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 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("/", "/", "")); - } -} 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 f3f347d105..0000000000 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java +++ /dev/null @@ -1,759 +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.nio.charset.StandardCharsets; -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 { - 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 String getGeneratedFilename() { - return getGeneratedType().replace('.', '/') + (isKt() ? ".kt" : ".java"); - } - - 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()); - } - - 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()).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); - - 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 { - 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 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; - } - return generateJsonRpcService(generateKotlin == Boolean.TRUE || isKt()); - } - - 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))) { - buffer.deleteCharAt(i); - i = buffer.length() - 1; - } - return buffer; - } - - 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/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..7f4f09b500 --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java @@ -0,0 +1,347 @@ +/* + * 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(RestRouter router, ExecutableElement method, TypeElement httpMethodAnnotation) { + super(router, method); + this.httpMethodAnnotation = httpMethodAnnotation; + this.generatedName = method.getSimpleName().toString(); + } + + public String getGeneratedName() { + return generatedName; + } + + public void setGeneratedName(String generatedName) { + this.generatedName = generatedName; + } + + 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(router.getTargetType()) : 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, 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(", ")"))); + } + + 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(router.getTargetType(), method, httpMethodAnnotation); + + 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, methodName)))); + + 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.getLast(); + 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 customReturnType = getReturnType(); + var returnTypeGenerics = + customReturnType.getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); + var returnTypeString = type(kt, customReturnType.toString()); + + String projection = getProjection(); + + boolean isProjectedReturnType = + customReturnType.isProjection() || customReturnType.is(Types.PROJECTED); + + // Create separate variables for the generated HTTP handler's signature + var handlerTypeGenerics = returnTypeGenerics; + var handlerTypeString = returnTypeString; + + // ONLY modify the signature if we need to wrap a NON-projected type + if (projection != null && !isProjectedReturnType) { + handlerTypeGenerics = ""; + handlerTypeString = Types.PROJECTED + "<" + returnTypeString + ">"; + } + + methodCallHeader( + kt, + methodName, + buffer, + handlerTypeGenerics, + handlerTypeString, + !method.getThrownTypes().isEmpty()); + + int controllerIndent = 2; + + for (var parameter : getParameters(true)) { + var 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))); + + 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")) { + var call = makeCall(kt, paramList.toString(), false, false); + + buffer.add( + statement( + 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 call = makeCall(kt, paramList.toString(), isProjectedReturnType, isProjectedReturnType); + var nullable = kt && isNullableKotlinReturn(); + + // 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), + "return ", + projected, + nullable ? "!!" : "", + semicolon(kt))); + } else { + buffer.add( + statement( + indent(controllerIndent), "return ", call, 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 void methodCallHeader( + boolean kt, + String methodName, + ArrayList buffer, + String returnTypeGenerics, + String returnTypeString, + boolean throwsException) { + if (kt) { + 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 ctx = handler.ctx")); + } else { + buffer.add( + statement( + "fun ", + returnTypeGenerics, + methodName, + "(ctx: io.jooby.Context): ", + returnTypeString, + " {")); + } + } else { + buffer.add( + statement( + "public ", + returnTypeGenerics, + returnTypeString, + " ", + methodName, + "(io.jooby.Context ctx)", + throwsException ? "throws Exception {" : "{")); + } + } + + private String getProjection() { + var project = AnnotationSupport.findAnnotationByName(method, Types.PROJECT); + if (project != null) { + return AnnotationSupport.findAnnotationValue(project, VALUE).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 new file mode 100644 index 0000000000..8659360b3c --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRouter.java @@ -0,0 +1,153 @@ +/* + * 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) { + var router = new RestRouter(context, controller); + + for (var type : context.superTypes(controller)) { + for (var enclosed : type.getEnclosedElements()) { + if (enclosed.getKind() == ElementKind.METHOD) { + var method = (ExecutableElement) enclosed; + + // Ignore abstract methods + if (method.getModifiers().contains(javax.lang.model.element.Modifier.ABSTRACT)) { + continue; + } + + for (var annoMirror : method.getAnnotationMirrors()) { + var annoElement = (TypeElement) annoMirror.getAnnotationType().asElement(); + + if (HttpMethod.hasAnnotation(annoElement)) { + var route = new RestRoute(router, method, annoElement); + var uniqueKey = method.toString() + annoElement.getSimpleName(); + router.routes.putIfAbsent(uniqueKey, route); + } + } + } + } + } + + // Resolve Overloads + var grouped = + router.routes.values().stream().collect(Collectors.groupingBy(RestRoute::getMethodName)); + for (var overloads : grouped.values()) { + 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() + .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 toSourceCode(boolean kt) throws IOException { + var generateTypeName = getTargetType().getSimpleName().toString(); + var generatedClass = getGeneratedType().substring(getGeneratedType().lastIndexOf('.') + 1); + + 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(); + + 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()); + + 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)); + + 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..5c21b564bb --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRoute.java @@ -0,0 +1,632 @@ +/* + * 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(TrpcRouter router, ExecutableElement method) { + super(router, method); + this.resolvedTrpcMethod = discoverTrpcMethod(); + this.generatedName = method.getSimpleName().toString(); + } + + public void setGeneratedName(String generatedName) { + this.generatedName = 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; + } + throw new IllegalStateException("Unable to find tRPC method: " + method); + } + + private 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( + of( + 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.getLast(); + 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 optimally + for (var parameter : parameters) { + var parameterName = parameter.getName(); + 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; + } + 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 ", + parameterName, + " = if (reader.nextIsNull(", + string(parameterName), + ")) null else reader.", + readName, + "(", + string(parameterName), + ")")); + } else { + buffer.add( + statement( + indent(4), + var(kt), + parameterName, + " = reader.nextIsNull(", + string(parameterName), + ") ? null : reader.", + readName, + "(", + string(parameterName), + ")", + semicolon(kt))); + } + } else { + buffer.add( + statement( + indent(4), + var(kt), + parameterName, + " = reader.", + readName, + "(", + string(parameterName), + ")", + semicolon(kt))); + } + paramList.add(parameterName); + 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 ", + parameterName, + " = if (reader.nextIsNull(", + string(parameterName), + ")) null else reader.", + readMethod, + "(", + string(parameterName), + ")", + ktCast)); + } else { + var targetType = type.replace("java.lang.", ""); + var javaPrefix = isChar ? "" : "(" + targetType + ") "; + var javaSuffix = isChar ? ".charAt(0)" : ""; + buffer.add( + statement( + indent(4), + var(kt), + parameterName, + " = reader.nextIsNull(", + string(parameterName), + ") ? null : ", + javaPrefix, + "reader.", + readMethod, + "(", + string(parameterName), + ")", + 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), + parameterName, + " = reader.", + readMethod, + "(", + string(parameterName), + ")", + 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), + parameterName, + " = ", + javaPrefix, + "reader.", + readMethod, + "(", + string(parameterName), + ")", + javaSuffix, + semicolon(kt))); + } + } + paramList.add(parameterName); + break; + + default: + var genericType = kt ? type : box(type); // Box primitives for Java Generics + if (kt) { + buffer.add( + statement( + indent(4), + "val ", + parameterName, + "Decoder: io.jooby.rpc.trpc.TrpcDecoder<", + type, + "> = parser.decoder(", + parameter.getType().toSourceCode(kt), + ")")); + if (isNullable) { + buffer.add( + statement( + indent(4), + "val ", + parameterName, + " = if (reader.nextIsNull(", + string(parameterName), + ")) null else reader.nextObject(", + string(parameterName), + ", ", + parameterName, + "Decoder)")); + } else { + buffer.add( + statement( + indent(4), + "val ", + parameterName, + " = reader.nextObject(", + string(parameterName), + ", ", + parameterName, + "Decoder)")); + } + } else { + buffer.add( + statement( + indent(4), + "io.jooby.rpc.trpc.TrpcDecoder<", + genericType, + "> ", + parameterName, + "Decoder = parser.decoder(", + parameter.getType().toSourceCode(kt), + ")", + semicolon(false))); + if (isNullable) { + buffer.add( + statement( + indent(4), + type, + " ", + parameterName, + " = reader.nextIsNull(", + string(parameterName), + ") ? null : reader.nextObject(", + string(parameterName), + ", ", + parameterName, + "Decoder)", + semicolon(false))); + } else { + buffer.add( + statement( + indent(4), + type, + " ", + parameterName, + " = reader.nextObject(", + string(parameterName), + ", ", + parameterName, + "Decoder)", + semicolon(false))); + } + } + paramList.add(parameterName); + break; + } + } + } + + buffer.add( + statement(indent(controllerIndent), var(kt), "c = this.factory.apply(ctx)", semicolon(kt))); + + // Pass 'true' for isRpcWrapper so it safely casts List to List + var call = makeCall(kt, paramList.toString(), false, true); + var nullable = kt && isNullableKotlinReturn(); + + 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( + 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, + nullable ? "!!" : "", // Shared nullability check + ")", + semicolon(kt))); + } + + if (!parameters.isEmpty()) buffer.add(statement(indent(2), "}")); + buffer.add(statement("}", System.lineSeparator())); + + // Suppress both UNCHECKED_CAST and USELESS_CAST to keep the Kotlin compiler perfectly quiet + if (isUncheckedCast()) { + if (kt) buffer.addFirst(statement("@Suppress(\"UNCHECKED_CAST\", \"USELESS_CAST\")")); + else buffer.addFirst(statement("@SuppressWarnings(\"unchecked\")")); + } + + 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..e002c35b78 --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TrpcRouter.java @@ -0,0 +1,134 @@ +/* + * 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); + + // 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); + } + } + } + } + + // 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() + "Trpc"); + } + + @Override + public String toSourceCode(boolean kt) throws IOException { + var generateTypeName = getTargetType().getSimpleName().toString(); + var generatedClass = getGeneratedType().substring(getGeneratedType().lastIndexOf('.') + 1); + + var template = getTemplate(kt); + 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..fcd3380415 --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRoute.java @@ -0,0 +1,203 @@ +/* + * 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.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; + +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 R router; + private boolean uncheckedCast; + + public WebRoute(R 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.getLast().getType().is("kotlin.coroutines.Continuation"); + this.returnType = + new TypeDefinition( + context.getProcessingEnvironment().getTypeUtils(), method.getReturnType()); + } + + public R 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(); + } + + 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(); + var elements = processingEnv.getElementUtils(); + + if (returnType.isVoid()) { + return new TypeDefinition(types, elements.getTypeElement("io.jooby.StatusCode").asType()); + } else if (isSuspendFun()) { + var continuation = parameters.getLast().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(); + } + + 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. + * + * @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; + } + + protected 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(); + } + + protected boolean isNullableKotlinReturn() { + return method.getAnnotationMirrors().stream() + .map(javax.lang.model.element.AnnotationMirror::getAnnotationType) + .map(java.util.Objects::toString) + .anyMatch(AnnotationSupport.NULLABLE); + } + + protected String makeCall( + 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; + }; + } +} 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..88afba27e6 --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRouter.java @@ -0,0 +1,327 @@ +/* + * 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 -> 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 toSourceCode(boolean kt) 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); + } + + 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))) { + 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/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/io/jooby/apt/ProcessorRunner.java b/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java index 15f39a62da..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 @@ -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; @@ -79,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).getRestSourceCode(true)); + kotlinFiles.put(classname, context.getRouters().get(0).toSourceCode(true)); } catch (IOException e) { SneakyThrows.propagate(e); } @@ -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/i3830/ExampleServer.java b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java new file mode 100644 index 0000000000..3524077c77 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java @@ -0,0 +1,86 @@ +/* + * 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.mcp.*; +import io.modelcontextprotocol.spec.McpSchema; + +@McpServer("example-server") +public class ExampleServer { + + /** + * Add two numbers. A simple calculator. + * + * @param a 1st number + * @return sum of the two numbers + */ + @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; + } + + // 2. Prompt + + /** + * 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 + * @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) { + return "Please review this " + language + " code:\n" + code; + } + + /** Logs Title. Log description Suspendisse potenti. */ + @McpResource( + uri = "file:///logs/app.log", + name = "Application Logs", + size = 1024, + annotations = + @McpResource.McpAnnotations( + audience = McpSchema.Role.USER, + lastModified = "1", + priority = 1.5)) + public String getLogs() { + return "Log content here..."; + } + + /** + * 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"); + } + + // 5. Completion (Linked to the Resource Template 'id' argument) + @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/Issue3830.java b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java new file mode 100644 index 0000000000..339f535746 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java @@ -0,0 +1,233 @@ +/* + * 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 Issue3830 { + @Test + public void shouldGenerateMcpServer() throws Exception { + new ProcessorRunner(new ExampleServer()) + .withMcpCode( + source -> { + assertThat(source) + .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 -> 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; + private boolean generateOutputSchema = false; + @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 io.jooby.mcp.McpService generateOutputSchema(boolean generateOutputSchema) { + this.generateOutputSchema = generateOutputSchema; + return this; + } + + @Override + public String serverKey() { + return "example-server"; + } + + @Override + 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(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; + } + + @Override + 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(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; + } + + @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) -> 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 + 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) -> 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) { + 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 = (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"); + if (raw_a == null) throw new IllegalArgumentException("Missing req param: a"); + 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 = ((Number) raw_b).intValue(); + 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 = (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"); + 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 = (io.jooby.Context) 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", "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 = (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(); + 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 = (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() : ""; + 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 = (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() : ""; + 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/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-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 0a545e471d..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,12 +91,12 @@ public void shouldFollowNullability() throws Exception { @Test public void shouldGenerateDefaultConstructorForDI() throws Exception { new ProcessorRunner(new DIService(null)) - .withSourceCode( + .withRpcCode( 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-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"; + } +} 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-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-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/modules/jooby-mcp-jackson2/pom.xml b/modules/jooby-mcp-jackson2/pom.xml new file mode 100644 index 0000000000..d097cd2b72 --- /dev/null +++ b/modules/jooby-mcp-jackson2/pom.xml @@ -0,0 +1,47 @@ + + + 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 + + + + + + org.apache.maven.plugins + maven-jar-plugin + ${maven-jar-plugin.version} + + + + io.jooby.mcp.jackson2 + + + + + + + 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..bab37e6c86 --- /dev/null +++ b/modules/jooby-mcp-jackson3/pom.xml @@ -0,0 +1,47 @@ + + + 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 + + + + + + org.apache.maven.plugins + maven-jar-plugin + ${maven-jar-plugin.version} + + + + io.jooby.mcp.jackson3 + + + + + + + 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 new file mode 100644 index 0000000000..36cb165790 --- /dev/null +++ b/modules/jooby-mcp/pom.xml @@ -0,0 +1,43 @@ + + + 4.0.0 + + io.jooby + modules + 4.1.1-SNAPSHOT + + + jooby-mcp + jooby-mcp + + + + io.jooby + jooby + ${jooby.version} + + + io.modelcontextprotocol.sdk + 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/annotation/mcp/McpCompletion.java b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpCompletion.java new file mode 100644 index 0000000000..d557eb4c57 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpCompletion.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.annotation.mcp; + +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(); +} 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/annotation/mcp/McpParam.java b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpParam.java new file mode 100644 index 0000000000..48aba66436 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/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.mcp; + +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/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 new file mode 100644 index 0000000000..198dc35a3d --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpPrompt.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.annotation.mcp; + +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 ""; + + /** Optional human-readable name of the prompt for display purposes. */ + String title() default ""; + + /** + * A description of what the prompt provides. + * + * @return Prompt description. + */ + String description() default ""; +} 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 new file mode 100644 index 0000000000..4ea5d3cd2a --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpResource.java @@ -0,0 +1,86 @@ +/* + * 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; + +import io.modelcontextprotocol.spec.McpSchema; + +/** + * 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 uri(); + + /** + * The name of the resource. + * + * @return Resource name. + */ + String name() default ""; + + /** Optional human-readable name of the prompt for display purposes. */ + String title() 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 ""; + + /** Optional size in bytes. */ + int size() default -1; + + /** Optional MCP metadata annotations for this resource. */ + McpAnnotations annotations() default + @McpAnnotations( + audience = {McpSchema.Role.USER}, + lastModified = "", + priority = 0.5); + + @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”]). + */ + McpSchema.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-mcp/src/main/java/io/jooby/annotation/mcp/McpServer.java b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpServer.java new file mode 100644 index 0000000000..97c604b39f --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/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.mcp; + +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/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 new file mode 100644 index 0000000000..3671e31c74 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/annotation/mcp/McpTool.java @@ -0,0 +1,80 @@ +/* + * 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; + +/** 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 ""; + + /** + * 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; + } +} 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..ea10f0158f --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/DefaultMcpInvoker.java @@ -0,0 +1,62 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +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; + +public class DefaultMcpInvoker implements McpInvoker { + private final Jooby application; + + public DefaultMcpInvoker(Jooby application) { + this.application = application; + } + + @SuppressWarnings("unchecked") + @Override + public @NonNull R invoke(McpOperation operation, SneakyThrows.Supplier action) { + try { + return action.get(); + } catch (McpError mcpError) { + throw mcpError; + } catch (Throwable cause) { + 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)); + } + } + + 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/internal/mcp/McpServerConfig.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpServerConfig.java new file mode 100644 index 0000000000..e5fbedc2f7 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpServerConfig.java @@ -0,0 +1,163 @@ +/* + * 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; +import io.jooby.mcp.McpModule; + +/** + * @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 McpModule.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 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 McpModule.Transport getTransport() { + return transport; + } + + public void setTransport(McpModule.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(String key, Config config) { + var srvConfig = + new McpServerConfig( + resolveRequiredParam(config, "name"), resolveRequiredParam(config, "version")); + + if (config.hasPath("transport")) { + McpModule.Transport transport = McpModule.Transport.of(config.getString("transport")); + srvConfig.setTransport(transport); + } else { + srvConfig.setTransport(McpModule.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 == McpModule.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/transport/AbstractMcpTransport.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/AbstractMcpTransport.java new file mode 100644 index 0000000000..2ea6703138 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/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.internal.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/internal/mcp/transport/AbstractMcpTransportProvider.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/AbstractMcpTransportProvider.java new file mode 100644 index 0000000000..4b441ed9f8 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/AbstractMcpTransportProvider.java @@ -0,0 +1,89 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.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 a 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/internal/mcp/transport/SendError.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/SendError.java new file mode 100644 index 0000000000..1e10cd33ac --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/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.internal.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/internal/mcp/transport/SseTransportProvider.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/SseTransportProvider.java new file mode 100644 index 0000000000..2a5ccdd70a --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/SseTransportProvider.java @@ -0,0 +1,140 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.mcp.transport; + +import static io.jooby.internal.mcp.transport.TransportConstants.*; + +import java.io.IOException; + +import io.jooby.*; +import io.jooby.internal.mcp.McpServerConfig; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.server.McpTransportContextExtractor; +import io.modelcontextprotocol.spec.*; +import reactor.core.publisher.Mono; + +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; + + public SseTransportProvider( + Jooby app, + McpServerConfig serverConfig, + McpJsonMapper mcpJsonMapper, + McpTransportContextExtractor contextExtractor) { + super(mcpJsonMapper, contextExtractor); + this.messageEndpoint = serverConfig.getMessageEndpoint(); + var 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 + protected String transportName() { + return "SSE"; + } + + private void handleSseConnection(ServerSentEmitter sse) { + 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); + + sse.onClose( + () -> { + log.debug("Session with ID {} has been cancelled", sessionId); + sessions.remove(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") + .build(); + } + + var sessionId = ctx.query(SESSION_ID_KEY).value(); + var session = sessions.get(sessionId); + + if (session == null) { + ctx.setResponseCode(StatusCode.NOT_FOUND); + return McpError.builder(McpSchema.ErrorCodes.RESOURCE_NOT_FOUND) + .message("Session not found") + .build(); + } + + try { + var transportContext = this.contextExtractor.extract(ctx); + var body = ctx.body().value(); + var message = McpSchema.deserializeJsonRpcMessage(this.mcpJsonMapper, body); + + return session + .handle(message) + .contextWrite(reactorCtx -> reactorCtx.put(McpTransportContext.KEY, transportContext)) + .then(Mono.just((Object) StatusCode.OK)) + .onErrorResume( + error -> { + 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 a message", e); + return McpError.builder(McpSchema.ErrorCodes.PARSE_ERROR) + .message("Invalid message format") + .build(); + } + } + + private static class JoobyMcpSessionTransport extends AbstractMcpTransport { + private final ServerSentEmitter sse; + + public JoobyMcpSessionTransport(McpJsonMapper mcpJsonMapper, ServerSentEmitter sse) { + super(mcpJsonMapper); + this.sse = sse; + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + return Mono.fromRunnable( + () -> { + try { + var jsonText = mcpJsonMapper.writeValueAsString(message); + sse.send(new ServerSentMessage(jsonText).setEvent(MESSAGE_EVENT_TYPE)); + } catch (Exception e) { + log.error("Failed to send a message", e); + sse.send(SSE_ERROR_EVENT, e.getMessage()); + } + }); + } + + @Override + public void close() { + sse.close(); + } + } +} 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 new file mode 100644 index 0000000000..c3eb4df42b --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/StatelessTransportProvider.java @@ -0,0 +1,122 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.mcp.transport; + +import static io.jooby.internal.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 StatelessTransportProvider implements McpStatelessServerTransport { + private final Logger log = LoggerFactory.getLogger(getClass()); + private McpStatelessServerHandler mcpHandler; + private final McpJsonMapper mcpJsonMapper; + private final McpTransportContextExtractor contextExtractor; + private volatile boolean isClosing = false; + + public StatelessTransportProvider( + 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)); + } + + 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"); + } + var message = McpSchema.deserializeJsonRpcMessage(mcpJsonMapper, body); + + if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { + try { + return this.mcpHandler + .handleRequest(transportContext, jsonrpcRequest) + .contextWrite(reactorCtx -> reactorCtx.put(McpTransportContext.KEY, transportContext)) + .block(); + } 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 a 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/internal/mcp/transport/StreamableTransportProvider.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/StreamableTransportProvider.java new file mode 100644 index 0000000000..f7c6351750 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/StreamableTransportProvider.java @@ -0,0 +1,340 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.mcp.transport; + +import static io.jooby.internal.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 java.util.concurrent.ConcurrentMap; + +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. */ +@SuppressWarnings("PMD") +public class StreamableTransportProvider implements McpStreamableServerTransportProvider { + private final Logger log = LoggerFactory.getLogger(getClass()); + private final boolean disallowDelete; + private final McpJsonMapper mcpJsonMapper; + private final ConcurrentMap sessions = + new ConcurrentHashMap<>(); + private final McpTransportContextExtractor contextExtractor; + private volatile boolean isClosing = false; + private McpStreamableServerSession.Factory sessionFactory; + private KeepAliveScheduler keepAliveScheduler; + + public StreamableTransportProvider( + 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(); + } + } + + 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)); + if (ctx.header(HttpHeaders.MCP_SESSION_ID).isMissing()) return SendError.missingSessionId(ctx); + + var sessionId = ctx.header(HttpHeaders.MCP_SESSION_ID).value(); + var session = this.sessions.get(sessionId); + if (session == null) return SendError.sessionNotFound(ctx, 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)); + var sessionTransport = new StreamableMcpSessionTransport(sessionId, sse); + + if (ctx.header(HttpHeaders.LAST_EVENT_ID).isPresent()) { + var lastId = ctx.header(HttpHeaders.LAST_EVENT_ID).value(); + + // FIX: Replaced blocking .forEach with non-blocking .concatMap + session + .replay(lastId) + .contextWrite( + reactorCtx -> reactorCtx.put(McpTransportContext.KEY, transportContext)) + .concatMap( + message -> + sessionTransport + .sendMessage(message) + .contextWrite( + reactorCtx -> + reactorCtx.put(McpTransportContext.KEY, transportContext))) + .subscribe( + null, + error -> { + log.error("Failed to replay messages", error); + sse.send(SSE_ERROR_EVENT, error.getMessage()); + }); + } else { + var 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); + return SendError.internalError(ctx, sessionId); + } + } + + 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)); + } + + var 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"); + + var message = McpSchema.deserializeJsonRpcMessage(mcpJsonMapper, body); + + if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest + && McpSchema.METHOD_INITIALIZE.equals(jsonrpcRequest.method())) { + var initRequest = + mcpJsonMapper.convertValue(jsonrpcRequest.params(), McpSchema.InitializeRequest.class); + var initObj = this.sessionFactory.startSession(initRequest); + sessionId = initObj.session().getId(); + this.sessions.put(sessionId, initObj.session()); + + try { + 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); + return SendError.internalError(ctx, sessionId); + } + } + + if (ctx.header(HttpHeaders.MCP_SESSION_ID).isMissing()) + return SendError.missingSessionId(ctx); + sessionId = ctx.header(HttpHeaders.MCP_SESSION_ID).value(); + var 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)); + var sessionTransport = new StreamableMcpSessionTransport(finalSessionId, sse); + + session + .responseStream(jsonrpcRequest, sessionTransport) + .contextWrite( + reactorCtx -> reactorCtx.put(McpTransportContext.KEY, transportContext)) + .subscribe( + null, + error -> { + log.error("Failed to handle request stream", error); + sse.send(SSE_ERROR_EVENT, error.getMessage()); + sse.close(); + }); + }); + } else { + return SendError.unknownMsgType(ctx, sessionId); + } + } catch (IllegalArgumentException | IOException e) { + log.error("Failed to deserialize message", e); + return SendError.msgParseError(ctx, sessionId); + } catch (Exception e) { + log.error("Unexpected error occurred while handling message", e); + return SendError.internalError(ctx, sessionId); + } + } + + private Object handleDelete(Context 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); + + var sessionId = ctx.header(HttpHeaders.MCP_SESSION_ID).value(); + var session = this.sessions.get(sessionId); + if (session == null) return SendError.sessionNotFound(ctx, sessionId); + + try { + var transportContext = this.contextExtractor.extract(ctx); + 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); + 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()) return Mono.empty(); + + 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)) + .onErrorComplete()) + .then(); + } + + @Override + public Mono closeGracefully() { + return Flux.fromIterable(sessions.values()) + .doFirst(() -> this.isClosing = true) + .flatMap(McpStreamableServerSession::closeGracefully) + .doFinally( + signalType -> { + this.sessions.clear(); + if (this.keepAliveScheduler != null) this.keepAliveScheduler.shutdown(); + }) + .then(); + } + + private class StreamableMcpSessionTransport implements McpStreamableServerTransport { + private final String sessionId; + private final ServerSentEmitter sse; + private volatile boolean closed = false; + + StreamableMcpSessionTransport(String sessionId, ServerSentEmitter sse) { + this.sessionId = sessionId; + this.sse = sse; + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + return sendMessage(message, null); + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId) { + return Mono.fromRunnable( + () -> { + try { + if (!closed) { + 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); + try { + sse.send(SSE_ERROR_EVENT, e.getMessage()); + } catch (Exception errorEx) { + log.error("Failed to send error to SSE session {}", this.sessionId, errorEx); + } + } + }); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return mcpJsonMapper.convertValue(data, typeRef); + } + + @Override + public Mono closeGracefully() { + // 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)); + } + + @Override + public void close() { + try { + if (!this.closed) { + this.closed = true; + sse.close(); + } + } catch (Exception e) { + log.debug("Failed to close SSE session {}", sessionId, e); + } + } + } +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/TransportConstants.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/TransportConstants.java new file mode 100644 index 0000000000..c4e65ace70 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/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.internal.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/main/java/io/jooby/internal/mcp/transport/WebSocketTransportProvider.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/WebSocketTransportProvider.java new file mode 100644 index 0000000000..f4fa3165b2 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/WebSocketTransportProvider.java @@ -0,0 +1,135 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.mcp.transport; + +import java.io.IOException; + +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.server.McpTransportContextExtractor; +import io.modelcontextprotocol.spec.McpSchema; +import reactor.core.publisher.Mono; + +@SuppressWarnings("PMD") +public class WebSocketTransportProvider extends AbstractMcpTransportProvider { + + private static final String MCP_SESSION_ATTRIBUTE = "mcpSessionId"; + + public WebSocketTransportProvider( + Jooby app, + McpServerConfig serverConfig, + McpJsonMapper mcpJsonMapper, + McpTransportContextExtractor contextExtractor) { + super(mcpJsonMapper, 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 + protected String transportName() { + return "WebSocket"; + } + + private void handleConnect(WebSocket ws) { + if (isClosing.get()) { + ws.close(WebSocketCloseStatus.SERVICE_RESTARTED); + return; + } + + 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); + 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 || !sessions.containsKey(sessionId)) { + log.warn("Received message on unknown or orphaned WS session ID: {}", sessionId); + return; + } + + try { + var ctx = ws.getContext(); + var transportContext = this.contextExtractor.extract(ctx); + var message = McpSchema.deserializeJsonRpcMessage(this.mcpJsonMapper, msg.value()); + + sessions + .get(sessionId) + .handle(message) + .contextWrite(reactorCtx -> reactorCtx.put(McpTransportContext.KEY, transportContext)) + .subscribe( + null, + error -> + log.error( + "Error processing WS message for {}: {}", 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) { + log.error("WebSocket error for session: {}", ws.attribute(MCP_SESSION_ATTRIBUTE), cause); + } + + private static class JoobyMcpWebSocketTransport extends AbstractMcpTransport { + private final WebSocket ws; + private volatile boolean closed = false; + + public JoobyMcpWebSocketTransport(McpJsonMapper mcpJsonMapper, WebSocket ws) { + super(mcpJsonMapper); + this.ws = ws; + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + return Mono.fromRunnable( + () -> { + try { + if (!closed) ws.send(mcpJsonMapper.writeValueAsString(message)); + } catch (Exception e) { + log.error("Failed to send WebSocket message", e); + } + }); + } + + @Override + public void close() { + if (!closed) { + closed = true; + ws.close(WebSocketCloseStatus.NORMAL); + } + } + } +} 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..1fe85baff6 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInspectorModule.java @@ -0,0 +1,201 @@ +/* + * 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 4.2.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 + + + + + +
+ + + %3$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(inspectorEndpoint + "/static/*", "/mcpInspector/assets/"); + + app.get(inspectorEndpoint, ctx -> ctx.setResponseType(MediaType.html).render(this.indexHtml)); + + app.get( + inspectorEndpoint + "/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.formatted(inspectorEndpoint) : ""; + return INDEX_HTML_TEMPLATE.formatted(DIST, inspectorEndpoint, 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.getFirst(); + } + + private String buildConfigJson(McpServerConfig config, String location) { + var endpoint = resolveEndpoint(config); + var transport = + config.isSseTransport() ? McpModule.Transport.SSE : McpModule.Transport.STREAMABLE_HTTP; + 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(); + } + } +} 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..9b8c56a61e --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInvoker.java @@ -0,0 +1,82 @@ +/* + * 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; + +/** + * 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 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(McpOperation operation, 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(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 new file mode 100644 index 0000000000..cc98c1e1cc --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java @@ -0,0 +1,408 @@ +/* + * 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.SneakyThrows.throwingConsumer; +import static io.jooby.mcp.McpModule.Transport.STATELESS_STREAMABLE_HTTP; +import static io.jooby.mcp.McpModule.Transport.STREAMABLE_HTTP; + +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; +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; +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.*; +import io.modelcontextprotocol.spec.McpSchema; + +/** + * 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 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. + *
+ * + *

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 McpInvoker}: + * + *

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

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. + * + *

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 McpInspectorModule} alongside this module to interactively execute your tools, + * prompts, and resources straight from your browser. + * + * @author kliushnichenko + * @author edgar + * @since 4.2.0 + */ +public class McpModule implements Extension { + + private static final McpTransportContextExtractor CTX_EXTRACTOR = + ctx -> { + var transportContext = Map.of("HEADERS", ctx.headerMap(), "CTX", ctx); + return McpTransportContext.create(transportContext); + }; + + private static final String MODULE_CONFIG_PREFIX = "mcp"; + private static final Logger log = LoggerFactory.getLogger(McpModule.class); + + private Transport defaultTransport = STREAMABLE_HTTP; + + private final List mcpServices = new ArrayList<>(); + + private McpInvoker invoker; + + private Boolean generateOutputSchema = null; + + /** + * 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) { + Collections.addAll(this.mcpServices, 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) { + if (this.invoker != null) { + this.invoker = invoker.then(this.invoker); + } else { + this.invoker = 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) { + firstInvoker = firstInvoker.then(this.invoker); + } + services.put(McpInvoker.class, firstInvoker); + // Group services by server + var mcpServiceMap = new HashMap>(); + for (var mcpService : mcpServices) { + 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()) { + var mcpConfig = mcpServerConfig(app, serverEntry.getKey()); + // Internal usage only, required by mcp-inspector + services.listOf(McpServerConfig.class).add(mcpConfig); + + 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); + var statelessServer = + McpServer.sync(transport) + .serverInfo(mcpConfig.getName(), mcpConfig.getVersion()) + .completions(statelessCompletions(app, serverEntry)) + .capabilities(capabilities) + .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); + + stateless = true; + + 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 StreamableTransportProvider( + app, mcpJsonMapper, mcpConfig, CTX_EXTRACTOR)); + case SSE -> + McpServer.sync( + new SseTransportProvider(app, mcpConfig, mcpJsonMapper, CTX_EXTRACTOR)); + case WEBSOCKET -> + McpServer.sync( + new WebSocketTransportProvider( + app, mcpConfig, mcpJsonMapper, CTX_EXTRACTOR)); + default -> + throw new IllegalStateException( + "Unsupported transport: " + mcpConfig.getTransport()); + }) + .serverInfo(mcpConfig.getName(), mcpConfig.getVersion()) + .completions(completions(app, serverEntry)) + .capabilities(capabilities) + .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); + 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); + }); + } + } + + private static List completions( + Jooby application, Map.Entry> serverEntry) { + return serverEntry.getValue().stream() + .map(it -> it.completions(application)) + .flatMap(List::stream) + .toList(); + } + + private static List statelessCompletions( + Jooby application, Map.Entry> serverEntry) { + return serverEntry.getValue().stream() + .map(it -> it.statelessCompletions(application)) + .flatMap(List::stream) + .toList(); + } + + 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); + } + } + + public enum Transport { + SSE("sse"), + STREAMABLE_HTTP("streamable-http"), + STATELESS_STREAMABLE_HTTP("stateless-streamable-http"), + WEBSOCKET("web-socket"); + + private final String value; + + Transport(String value) { + this.value = value; + } + + public static Transport of(String value) { + for (var 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/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) {} 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..175712898c --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpResult.java @@ -0,0 +1,232 @@ +/* + * 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.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; + +/** + * 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) { + 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); + } + } + + /** + * 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()); + } 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)); + } + } + + /** + * 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) { + 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); + } + } + + /** + * 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"); + + 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); + } + } + } +} 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..f8449f4c06 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java @@ -0,0 +1,93 @@ +/* + * 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 io.jooby.Jooby; +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. + * + *

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(); + + McpService generateOutputSchema(boolean generateOutputSchema); +} 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; 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..57e8311d97 --- /dev/null +++ b/modules/jooby-mcp/src/main/resources/mcpInspector/assets/initScript-B8iPFz0O.js @@ -0,0 +1,76 @@ +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 cp = document.getElementById('contextPath').value; + const url = location.origin + cp; + patchMcpProxyAddress(INSPECTOR_CONFIG_KEY, url); +} + +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 @@ + + + + + + + + + + + + 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)); } } 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..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 diff --git a/pom.xml b/pom.xml index 7d1026cae3..c7244e22e5 100644 --- a/pom.xml +++ b/pom.xml @@ -263,6 +263,13 @@ pom import + + io.modelcontextprotocol.sdk + mcp-bom + 1.1.1 + pom + import + io.jooby @@ -571,6 +578,48 @@ ${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} + + + + io.jooby + jooby-mcp-inspector + ${jooby.version} + + com.github.ben-manes.caffeine caffeine diff --git a/tests/pom.xml b/tests/pom.xml index f9bbb8ffce..42a4bfc02d 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -146,6 +146,21 @@ jooby-avaje-validator ${jooby.version} + + io.jooby + 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/CalculatorTools.java b/tests/src/test/java/io/jooby/i3830/CalculatorTools.java new file mode 100644 index 0000000000..33bbf26b65 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3830/CalculatorTools.java @@ -0,0 +1,67 @@ +/* + * 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 java.util.Optional; + +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. */ +public class CalculatorTools { + + /** + * 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; + } + + @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."; + } + + @McpResource( + uri = "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( + uri = "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"; + } + + @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"); + } + + @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/CalculatorToolsTest.java b/tests/src/test/java/io/jooby/i3830/CalculatorToolsTest.java new file mode 100644 index 0000000000..1346840f3b --- /dev/null +++ b/tests/src/test/java/io/jooby/i3830/CalculatorToolsTest.java @@ -0,0 +1,583 @@ +/* + * 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.*; + +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; +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, McpModule.Transport transport) { + app.install(new Jackson3Module()); + app.install(new McpJackson3Module()); + app.install(new McpModule(new CalculatorToolsMcp_()).transport(transport)); + } + + @ServerTest + public void shouldCallToolOverStreamableHttp(ServerTestRunner runner) { + runner + .define(app -> setupMcpApp(app, 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 -> setupMcpApp(app, 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 + .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(); + 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 shouldCallToolOverWebSocket(ServerTestRunner runner) throws Exception { + runner + .define(app -> setupMcpApp(app, 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 + .define(app -> setupMcpApp(app, McpModule.Transport.STATELESS_STREAMABLE_HTTP)) + .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 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 + .define(app -> setupMcpApp(app, McpModule.Transport.STATELESS_STREAMABLE_HTTP)) + .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(app -> setupMcpApp(app, McpModule.Transport.STATELESS_STREAMABLE_HTTP)) + .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(app -> setupMcpApp(app, McpModule.Transport.STATELESS_STREAMABLE_HTTP)) + .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/McpExchangeInjectionTest.java b/tests/src/test/java/io/jooby/i3830/McpExchangeInjectionTest.java new file mode 100644 index 0000000000..55422c64eb --- /dev/null +++ b/tests/src/test/java/io/jooby/i3830/McpExchangeInjectionTest.java @@ -0,0 +1,105 @@ +/* + * 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; +import io.jooby.mcp.jackson3.McpJackson3Module; + +public class McpExchangeInjectionTest { + + @ServerTest + public void shouldInjectExchangeAndAccessSession(ServerTestRunner runner) throws Exception { + runner + .define( + app -> { + app.install(new Jackson3Module()); + app.install(new McpJackson3Module()); + // 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/UserTools.java b/tests/src/test/java/io/jooby/i3830/UserTools.java new file mode 100644 index 0000000000..018cfbb379 --- /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.mcp.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..9d06b2bf95 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3830/UserToolsTest.java @@ -0,0 +1,94 @@ +/* + * 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 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_()) + .generateOutputSchema(true) + .transport(McpModule.Transport.STATELESS_STREAMABLE_HTTP)); + } + + @ServerTest + public void shouldReturnStructuredJsonObjectOnJackson2(ServerTestRunner runner) { + runner + .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.postJson( + "/mcp", + jsonRpcRequest, + response -> { + Assertions.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"); + }); + }; + } +} 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