From 3884c2d11675939ec778b3c8f1a46f48e28d27cb Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 16 Mar 2026 12:45:32 -0300 Subject: [PATCH 01/56] prepare for next development cycle --- jooby/pom.xml | 2 +- modules/jooby-apt/pom.xml | 2 +- modules/jooby-avaje-inject/pom.xml | 2 +- modules/jooby-avaje-jsonb/pom.xml | 2 +- modules/jooby-avaje-validator/pom.xml | 2 +- modules/jooby-awssdk-v1/pom.xml | 2 +- modules/jooby-awssdk-v2/pom.xml | 2 +- modules/jooby-bom/pom.xml | 19 +++++++++++++++++-- modules/jooby-caffeine/pom.xml | 2 +- modules/jooby-camel/pom.xml | 2 +- modules/jooby-cli/pom.xml | 2 +- modules/jooby-commons-email/pom.xml | 2 +- modules/jooby-conscrypt/pom.xml | 2 +- modules/jooby-db-scheduler/pom.xml | 2 +- modules/jooby-distribution/pom.xml | 2 +- modules/jooby-ebean/pom.xml | 2 +- modules/jooby-flyway/pom.xml | 2 +- modules/jooby-freemarker/pom.xml | 2 +- modules/jooby-gradle-setup/pom.xml | 2 +- modules/jooby-graphiql/pom.xml | 2 +- modules/jooby-graphql/pom.xml | 2 +- modules/jooby-grpc/pom.xml | 2 +- modules/jooby-gson/pom.xml | 2 +- modules/jooby-guice/pom.xml | 2 +- modules/jooby-handlebars/pom.xml | 2 +- modules/jooby-hibernate-validator/pom.xml | 2 +- modules/jooby-hibernate/pom.xml | 2 +- modules/jooby-hikari/pom.xml | 2 +- modules/jooby-jackson/pom.xml | 2 +- modules/jooby-jackson3/pom.xml | 2 +- modules/jooby-jasypt/pom.xml | 2 +- modules/jooby-jdbi/pom.xml | 2 +- modules/jooby-jetty/pom.xml | 2 +- modules/jooby-jstachio/pom.xml | 2 +- modules/jooby-jte/pom.xml | 2 +- modules/jooby-jwt/pom.xml | 2 +- modules/jooby-kafka/pom.xml | 2 +- modules/jooby-kotlin/pom.xml | 2 +- modules/jooby-langchain4j/pom.xml | 2 +- modules/jooby-log4j/pom.xml | 2 +- modules/jooby-logback/pom.xml | 2 +- modules/jooby-maven-plugin/pom.xml | 2 +- modules/jooby-metrics/pom.xml | 2 +- modules/jooby-mutiny/pom.xml | 2 +- modules/jooby-netty/pom.xml | 2 +- modules/jooby-openapi/pom.xml | 2 +- modules/jooby-pac4j/pom.xml | 2 +- modules/jooby-pebble/pom.xml | 2 +- modules/jooby-quartz/pom.xml | 2 +- modules/jooby-reactor/pom.xml | 2 +- modules/jooby-redis/pom.xml | 2 +- modules/jooby-redoc/pom.xml | 2 +- modules/jooby-rocker/pom.xml | 2 +- modules/jooby-run/pom.xml | 2 +- modules/jooby-rxjava3/pom.xml | 2 +- modules/jooby-stork/pom.xml | 2 +- modules/jooby-swagger-ui/pom.xml | 2 +- modules/jooby-test/pom.xml | 2 +- modules/jooby-thymeleaf/pom.xml | 2 +- modules/jooby-trpc/pom.xml | 2 +- modules/jooby-undertow/pom.xml | 2 +- modules/jooby-vertx-mysql-client/pom.xml | 2 +- modules/jooby-vertx-pg-client/pom.xml | 2 +- modules/jooby-vertx-sql-client/pom.xml | 2 +- modules/jooby-vertx/pom.xml | 2 +- modules/jooby-whoops/pom.xml | 2 +- modules/jooby-yasson/pom.xml | 2 +- modules/pom.xml | 2 +- pom.xml | 4 ++-- tests/pom.xml | 2 +- 70 files changed, 87 insertions(+), 72 deletions(-) diff --git a/jooby/pom.xml b/jooby/pom.xml index ae1c1ebc0f..b9b8b634e7 100644 --- a/jooby/pom.xml +++ b/jooby/pom.xml @@ -6,7 +6,7 @@ io.jooby jooby-project - 4.1.0 + 4.1.1-SNAPSHOT jooby jooby diff --git a/modules/jooby-apt/pom.xml b/modules/jooby-apt/pom.xml index 1cb5531646..4f98b591cf 100644 --- a/modules/jooby-apt/pom.xml +++ b/modules/jooby-apt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-apt jooby-apt diff --git a/modules/jooby-avaje-inject/pom.xml b/modules/jooby-avaje-inject/pom.xml index d6f877b5b9..9ff786ea2d 100644 --- a/modules/jooby-avaje-inject/pom.xml +++ b/modules/jooby-avaje-inject/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-avaje-inject jooby-avaje-inject diff --git a/modules/jooby-avaje-jsonb/pom.xml b/modules/jooby-avaje-jsonb/pom.xml index f875687d40..c6263c0262 100644 --- a/modules/jooby-avaje-jsonb/pom.xml +++ b/modules/jooby-avaje-jsonb/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-avaje-jsonb jooby-avaje-jsonb diff --git a/modules/jooby-avaje-validator/pom.xml b/modules/jooby-avaje-validator/pom.xml index d5c8a582c3..2c5c6a9c61 100644 --- a/modules/jooby-avaje-validator/pom.xml +++ b/modules/jooby-avaje-validator/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-avaje-validator jooby-avaje-validator diff --git a/modules/jooby-awssdk-v1/pom.xml b/modules/jooby-awssdk-v1/pom.xml index da4252b05b..51d67d9fc0 100644 --- a/modules/jooby-awssdk-v1/pom.xml +++ b/modules/jooby-awssdk-v1/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-awssdk-v1 jooby-awssdk-v1 diff --git a/modules/jooby-awssdk-v2/pom.xml b/modules/jooby-awssdk-v2/pom.xml index a8cdcbf6a6..6592606605 100644 --- a/modules/jooby-awssdk-v2/pom.xml +++ b/modules/jooby-awssdk-v2/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-awssdk-v2 jooby-awssdk-v2 diff --git a/modules/jooby-bom/pom.xml b/modules/jooby-bom/pom.xml index e57f49a10b..efdacdb619 100644 --- a/modules/jooby-bom/pom.xml +++ b/modules/jooby-bom/pom.xml @@ -7,14 +7,14 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT io.jooby jooby-bom jooby-bom pom - 4.1.0 + 4.1.1-SNAPSHOT Jooby (Bill of Materials) https://jooby.io @@ -155,6 +155,11 @@ jooby-jackson ${project.version} + + io.jooby + jooby-jackson3 + ${project.version} + io.jooby jooby-jasypt @@ -195,6 +200,11 @@ jooby-kotlin ${project.version} + + io.jooby + jooby-langchain4j + ${project.version} + io.jooby jooby-log4j @@ -295,6 +305,11 @@ jooby-thymeleaf ${project.version} + + io.jooby + jooby-trpc + ${project.version} + io.jooby jooby-undertow diff --git a/modules/jooby-caffeine/pom.xml b/modules/jooby-caffeine/pom.xml index f9e1ad5101..305674fc50 100644 --- a/modules/jooby-caffeine/pom.xml +++ b/modules/jooby-caffeine/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-caffeine jooby-caffeine diff --git a/modules/jooby-camel/pom.xml b/modules/jooby-camel/pom.xml index 6bc1bab5e9..5f0a9ed429 100644 --- a/modules/jooby-camel/pom.xml +++ b/modules/jooby-camel/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-camel jooby-camel diff --git a/modules/jooby-cli/pom.xml b/modules/jooby-cli/pom.xml index bfafd5eec5..82ea1e64a9 100644 --- a/modules/jooby-cli/pom.xml +++ b/modules/jooby-cli/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-cli jooby-cli diff --git a/modules/jooby-commons-email/pom.xml b/modules/jooby-commons-email/pom.xml index b7ef6a69dd..de3f0a0d28 100644 --- a/modules/jooby-commons-email/pom.xml +++ b/modules/jooby-commons-email/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-commons-email jooby-commons-email diff --git a/modules/jooby-conscrypt/pom.xml b/modules/jooby-conscrypt/pom.xml index 6fd32ec2a7..3d2546ad48 100644 --- a/modules/jooby-conscrypt/pom.xml +++ b/modules/jooby-conscrypt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-conscrypt jooby-conscrypt diff --git a/modules/jooby-db-scheduler/pom.xml b/modules/jooby-db-scheduler/pom.xml index a274216bba..fb4bc5dc93 100644 --- a/modules/jooby-db-scheduler/pom.xml +++ b/modules/jooby-db-scheduler/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-db-scheduler jooby-db-scheduler diff --git a/modules/jooby-distribution/pom.xml b/modules/jooby-distribution/pom.xml index 2018ba7455..bb6b4d0070 100644 --- a/modules/jooby-distribution/pom.xml +++ b/modules/jooby-distribution/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-distribution jooby-distribution diff --git a/modules/jooby-ebean/pom.xml b/modules/jooby-ebean/pom.xml index d047cc0459..824949ccb9 100644 --- a/modules/jooby-ebean/pom.xml +++ b/modules/jooby-ebean/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-ebean jooby-ebean diff --git a/modules/jooby-flyway/pom.xml b/modules/jooby-flyway/pom.xml index ba7571fdd2..8b7f666609 100644 --- a/modules/jooby-flyway/pom.xml +++ b/modules/jooby-flyway/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-flyway jooby-flyway diff --git a/modules/jooby-freemarker/pom.xml b/modules/jooby-freemarker/pom.xml index b6a6234381..b0fc5278bf 100644 --- a/modules/jooby-freemarker/pom.xml +++ b/modules/jooby-freemarker/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-freemarker jooby-freemarker diff --git a/modules/jooby-gradle-setup/pom.xml b/modules/jooby-gradle-setup/pom.xml index f285be429b..68c0f9947b 100644 --- a/modules/jooby-gradle-setup/pom.xml +++ b/modules/jooby-gradle-setup/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-gradle-setup jooby-gradle-setup diff --git a/modules/jooby-graphiql/pom.xml b/modules/jooby-graphiql/pom.xml index 1bbc3bcb59..4f48357a73 100644 --- a/modules/jooby-graphiql/pom.xml +++ b/modules/jooby-graphiql/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-graphiql jooby-graphiql diff --git a/modules/jooby-graphql/pom.xml b/modules/jooby-graphql/pom.xml index 50892af380..5e4c7fc3d9 100644 --- a/modules/jooby-graphql/pom.xml +++ b/modules/jooby-graphql/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-graphql jooby-graphql diff --git a/modules/jooby-grpc/pom.xml b/modules/jooby-grpc/pom.xml index e8365d8e68..1975109225 100644 --- a/modules/jooby-grpc/pom.xml +++ b/modules/jooby-grpc/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-grpc jooby-grpc diff --git a/modules/jooby-gson/pom.xml b/modules/jooby-gson/pom.xml index 1cf6e93815..29b6287011 100644 --- a/modules/jooby-gson/pom.xml +++ b/modules/jooby-gson/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-gson jooby-gson diff --git a/modules/jooby-guice/pom.xml b/modules/jooby-guice/pom.xml index 4638437674..665bc60ec6 100644 --- a/modules/jooby-guice/pom.xml +++ b/modules/jooby-guice/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-guice jooby-guice diff --git a/modules/jooby-handlebars/pom.xml b/modules/jooby-handlebars/pom.xml index df87a86569..b651c3be0b 100644 --- a/modules/jooby-handlebars/pom.xml +++ b/modules/jooby-handlebars/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-handlebars jooby-handlebars diff --git a/modules/jooby-hibernate-validator/pom.xml b/modules/jooby-hibernate-validator/pom.xml index bf8a0dfb80..2413b5144d 100644 --- a/modules/jooby-hibernate-validator/pom.xml +++ b/modules/jooby-hibernate-validator/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-hibernate-validator jooby-hibernate-validator diff --git a/modules/jooby-hibernate/pom.xml b/modules/jooby-hibernate/pom.xml index 6db6b9a3fd..72800a7a9c 100644 --- a/modules/jooby-hibernate/pom.xml +++ b/modules/jooby-hibernate/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-hibernate jooby-hibernate diff --git a/modules/jooby-hikari/pom.xml b/modules/jooby-hikari/pom.xml index 4f877d5d81..5a2473e002 100644 --- a/modules/jooby-hikari/pom.xml +++ b/modules/jooby-hikari/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-hikari jooby-hikari diff --git a/modules/jooby-jackson/pom.xml b/modules/jooby-jackson/pom.xml index b4672a3248..1020443f05 100644 --- a/modules/jooby-jackson/pom.xml +++ b/modules/jooby-jackson/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-jackson jooby-jackson diff --git a/modules/jooby-jackson3/pom.xml b/modules/jooby-jackson3/pom.xml index 887614f0c1..9fa39d10aa 100644 --- a/modules/jooby-jackson3/pom.xml +++ b/modules/jooby-jackson3/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-jackson3 jooby-jackson3 diff --git a/modules/jooby-jasypt/pom.xml b/modules/jooby-jasypt/pom.xml index cbcdd849b3..fbb04fc0c9 100644 --- a/modules/jooby-jasypt/pom.xml +++ b/modules/jooby-jasypt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-jasypt jooby-jasypt diff --git a/modules/jooby-jdbi/pom.xml b/modules/jooby-jdbi/pom.xml index 0fe7fba813..41e2615cb8 100644 --- a/modules/jooby-jdbi/pom.xml +++ b/modules/jooby-jdbi/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-jdbi jooby-jdbi diff --git a/modules/jooby-jetty/pom.xml b/modules/jooby-jetty/pom.xml index 2c7f112278..618847afce 100644 --- a/modules/jooby-jetty/pom.xml +++ b/modules/jooby-jetty/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-jetty jooby-jetty diff --git a/modules/jooby-jstachio/pom.xml b/modules/jooby-jstachio/pom.xml index fbe1f92ede..a754c32dac 100644 --- a/modules/jooby-jstachio/pom.xml +++ b/modules/jooby-jstachio/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-jstachio jooby-jstachio diff --git a/modules/jooby-jte/pom.xml b/modules/jooby-jte/pom.xml index 837d924392..77bfae90a7 100644 --- a/modules/jooby-jte/pom.xml +++ b/modules/jooby-jte/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-jte jooby-jte diff --git a/modules/jooby-jwt/pom.xml b/modules/jooby-jwt/pom.xml index 600fdd5741..94f1400edc 100644 --- a/modules/jooby-jwt/pom.xml +++ b/modules/jooby-jwt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-jwt jooby-jwt diff --git a/modules/jooby-kafka/pom.xml b/modules/jooby-kafka/pom.xml index 1f72329637..dec63148fa 100644 --- a/modules/jooby-kafka/pom.xml +++ b/modules/jooby-kafka/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-kafka jooby-kafka diff --git a/modules/jooby-kotlin/pom.xml b/modules/jooby-kotlin/pom.xml index c902a922f8..492f4cd97e 100644 --- a/modules/jooby-kotlin/pom.xml +++ b/modules/jooby-kotlin/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-kotlin jooby-kotlin diff --git a/modules/jooby-langchain4j/pom.xml b/modules/jooby-langchain4j/pom.xml index 74f093246b..8a10043c9c 100644 --- a/modules/jooby-langchain4j/pom.xml +++ b/modules/jooby-langchain4j/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-langchain4j jooby-langchain4j diff --git a/modules/jooby-log4j/pom.xml b/modules/jooby-log4j/pom.xml index a6833cec32..b701ee0653 100644 --- a/modules/jooby-log4j/pom.xml +++ b/modules/jooby-log4j/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-log4j jooby-log4j diff --git a/modules/jooby-logback/pom.xml b/modules/jooby-logback/pom.xml index 4d18572faa..c49d73add1 100644 --- a/modules/jooby-logback/pom.xml +++ b/modules/jooby-logback/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-logback jooby-logback diff --git a/modules/jooby-maven-plugin/pom.xml b/modules/jooby-maven-plugin/pom.xml index a49b2197d8..980b785d2f 100644 --- a/modules/jooby-maven-plugin/pom.xml +++ b/modules/jooby-maven-plugin/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-maven-plugin jooby-maven-plugin diff --git a/modules/jooby-metrics/pom.xml b/modules/jooby-metrics/pom.xml index 551589b3e8..c466c36fa7 100644 --- a/modules/jooby-metrics/pom.xml +++ b/modules/jooby-metrics/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-metrics jooby-metrics diff --git a/modules/jooby-mutiny/pom.xml b/modules/jooby-mutiny/pom.xml index 9681543dc7..4a283898cb 100644 --- a/modules/jooby-mutiny/pom.xml +++ b/modules/jooby-mutiny/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-mutiny jooby-mutiny diff --git a/modules/jooby-netty/pom.xml b/modules/jooby-netty/pom.xml index cff8f4313b..da9294a223 100644 --- a/modules/jooby-netty/pom.xml +++ b/modules/jooby-netty/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-netty jooby-netty diff --git a/modules/jooby-openapi/pom.xml b/modules/jooby-openapi/pom.xml index f13c57f93e..2ff6b1b5ec 100644 --- a/modules/jooby-openapi/pom.xml +++ b/modules/jooby-openapi/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-openapi jooby-openapi diff --git a/modules/jooby-pac4j/pom.xml b/modules/jooby-pac4j/pom.xml index 6674e7fd12..08a6a1359f 100644 --- a/modules/jooby-pac4j/pom.xml +++ b/modules/jooby-pac4j/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-pac4j jooby-pac4j diff --git a/modules/jooby-pebble/pom.xml b/modules/jooby-pebble/pom.xml index bb168b15e7..96620ec88b 100644 --- a/modules/jooby-pebble/pom.xml +++ b/modules/jooby-pebble/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-pebble jooby-pebble diff --git a/modules/jooby-quartz/pom.xml b/modules/jooby-quartz/pom.xml index ba37cc5fb9..5b68342acd 100644 --- a/modules/jooby-quartz/pom.xml +++ b/modules/jooby-quartz/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-quartz jooby-quartz diff --git a/modules/jooby-reactor/pom.xml b/modules/jooby-reactor/pom.xml index 0b3691fad7..7a5bc99cde 100644 --- a/modules/jooby-reactor/pom.xml +++ b/modules/jooby-reactor/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-reactor jooby-reactor diff --git a/modules/jooby-redis/pom.xml b/modules/jooby-redis/pom.xml index 087e9fd7bf..ecee4f118b 100644 --- a/modules/jooby-redis/pom.xml +++ b/modules/jooby-redis/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-redis jooby-redis diff --git a/modules/jooby-redoc/pom.xml b/modules/jooby-redoc/pom.xml index d567b08208..fe324490e0 100644 --- a/modules/jooby-redoc/pom.xml +++ b/modules/jooby-redoc/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-redoc jooby-redoc diff --git a/modules/jooby-rocker/pom.xml b/modules/jooby-rocker/pom.xml index 738a1e05d0..502791f3e5 100644 --- a/modules/jooby-rocker/pom.xml +++ b/modules/jooby-rocker/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-rocker jooby-rocker diff --git a/modules/jooby-run/pom.xml b/modules/jooby-run/pom.xml index 3501025d33..bc90d398a6 100644 --- a/modules/jooby-run/pom.xml +++ b/modules/jooby-run/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-run jooby-run diff --git a/modules/jooby-rxjava3/pom.xml b/modules/jooby-rxjava3/pom.xml index 2378fb681f..7718a2d81d 100644 --- a/modules/jooby-rxjava3/pom.xml +++ b/modules/jooby-rxjava3/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-rxjava3 jooby-rxjava3 diff --git a/modules/jooby-stork/pom.xml b/modules/jooby-stork/pom.xml index a1af068585..25eb5fb2cd 100644 --- a/modules/jooby-stork/pom.xml +++ b/modules/jooby-stork/pom.xml @@ -4,7 +4,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-stork diff --git a/modules/jooby-swagger-ui/pom.xml b/modules/jooby-swagger-ui/pom.xml index ec2c2bad84..3cf88a076f 100644 --- a/modules/jooby-swagger-ui/pom.xml +++ b/modules/jooby-swagger-ui/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-swagger-ui jooby-swagger-ui diff --git a/modules/jooby-test/pom.xml b/modules/jooby-test/pom.xml index 4df0a07bff..14c5a2fb4a 100644 --- a/modules/jooby-test/pom.xml +++ b/modules/jooby-test/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-test jooby-test diff --git a/modules/jooby-thymeleaf/pom.xml b/modules/jooby-thymeleaf/pom.xml index d50c9b2dc9..fcb3241202 100644 --- a/modules/jooby-thymeleaf/pom.xml +++ b/modules/jooby-thymeleaf/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-thymeleaf jooby-thymeleaf diff --git a/modules/jooby-trpc/pom.xml b/modules/jooby-trpc/pom.xml index f28c0a4ec7..0a02bd412d 100644 --- a/modules/jooby-trpc/pom.xml +++ b/modules/jooby-trpc/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-trpc jooby-trpc diff --git a/modules/jooby-undertow/pom.xml b/modules/jooby-undertow/pom.xml index 25c1004e2b..0d98afdd02 100644 --- a/modules/jooby-undertow/pom.xml +++ b/modules/jooby-undertow/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-undertow jooby-undertow diff --git a/modules/jooby-vertx-mysql-client/pom.xml b/modules/jooby-vertx-mysql-client/pom.xml index 844bd7a74c..73b7fceb7f 100644 --- a/modules/jooby-vertx-mysql-client/pom.xml +++ b/modules/jooby-vertx-mysql-client/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-vertx-mysql-client jooby-vertx-mysql-client diff --git a/modules/jooby-vertx-pg-client/pom.xml b/modules/jooby-vertx-pg-client/pom.xml index 39095850bb..889beec48c 100644 --- a/modules/jooby-vertx-pg-client/pom.xml +++ b/modules/jooby-vertx-pg-client/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-vertx-pg-client jooby-vertx-pg-client diff --git a/modules/jooby-vertx-sql-client/pom.xml b/modules/jooby-vertx-sql-client/pom.xml index c6d04200f8..4138c53464 100644 --- a/modules/jooby-vertx-sql-client/pom.xml +++ b/modules/jooby-vertx-sql-client/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-vertx-sql-client jooby-vertx-sql-client diff --git a/modules/jooby-vertx/pom.xml b/modules/jooby-vertx/pom.xml index 5a086f7306..eb9cb1f912 100644 --- a/modules/jooby-vertx/pom.xml +++ b/modules/jooby-vertx/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-vertx jooby-vertx diff --git a/modules/jooby-whoops/pom.xml b/modules/jooby-whoops/pom.xml index 2599fb2ab2..fbe60130e5 100644 --- a/modules/jooby-whoops/pom.xml +++ b/modules/jooby-whoops/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-whoops jooby-whoops diff --git a/modules/jooby-yasson/pom.xml b/modules/jooby-yasson/pom.xml index fb301ad3ab..be4bd6cc32 100644 --- a/modules/jooby-yasson/pom.xml +++ b/modules/jooby-yasson/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.0 + 4.1.1-SNAPSHOT jooby-yasson jooby-yasson diff --git a/modules/pom.xml b/modules/pom.xml index da1708f668..5f14a492cf 100644 --- a/modules/pom.xml +++ b/modules/pom.xml @@ -4,7 +4,7 @@ io.jooby jooby-project - 4.1.0 + 4.1.1-SNAPSHOT modules diff --git a/pom.xml b/pom.xml index 055010deef..a223be2368 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.jooby jooby-project - 4.1.0 + 4.1.1-SNAPSHOT pom jooby-project @@ -212,7 +212,7 @@ 21 21 yyyy-MM-dd HH:mm:ssa - 2026-03-16T14:41:20Z + 2026-03-16T15:44:45Z UTF-8 etc${file.separator}source${file.separator}formatter.sh diff --git a/tests/pom.xml b/tests/pom.xml index bd40b4501b..f9bbb8ffce 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -6,7 +6,7 @@ io.jooby jooby-project - 4.1.0 + 4.1.1-SNAPSHOT tests tests From 51a3a33762f1ce95874aff9a0068803185ed28ed Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 16 Mar 2026 12:48:16 -0300 Subject: [PATCH 02/56] build: fix release notes --- .github/workflows/maven-central.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/maven-central.yml b/.github/workflows/maven-central.yml index 31e89b6ea8..25978492e8 100644 --- a/.github/workflows/maven-central.yml +++ b/.github/workflows/maven-central.yml @@ -76,6 +76,7 @@ jobs: gh issue list \ --repo ${{ github.repository }} \ --search "milestone:\"$VERSION_NUM\" label:feature" \ + --state all \ --limit 100 \ --json title,url \ --jq '.[] | "- [\(.title)](\(.url))"' > new_issues.md @@ -84,6 +85,7 @@ jobs: gh issue list \ --repo ${{ github.repository }} \ --search "milestone:\"$VERSION_NUM\" -label:feature -label:dependencies" \ + --state all \ --limit 100 \ --json title,url \ --jq '.[] | "- [\(.title)](\(.url))"' > other_issues.md From 493a9531ce3a897a431b00dba7a080762b9b96fb Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 16 Mar 2026 14:23:50 -0300 Subject: [PATCH 03/56] build: release notes v2 --- .github/workflows/maven-central.yml | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/.github/workflows/maven-central.yml b/.github/workflows/maven-central.yml index 25978492e8..3296cae60c 100644 --- a/.github/workflows/maven-central.yml +++ b/.github/workflows/maven-central.yml @@ -93,27 +93,34 @@ jobs: # 3. Initialize the changelog file > changelog.md - # 4. Conditionally add "What is new?" only if new_issues.md has content (-s) + # 4. Conditionally add "What's New" if new_issues.md has content (-s) if [ -s new_issues.md ]; then - echo "## What is new?" >> changelog.md + echo "## 🚀 What's New" >> changelog.md + echo "" >> changelog.md cat new_issues.md >> changelog.md echo "" >> changelog.md fi - # 5. Build the rest of the changelog ("Changes" is always present) - cat << EOF >> changelog.md - ## Changes - $(cat other_issues.md) + # 5. Conditionally add "Other Changes" if other_issues.md has content + if [ -s other_issues.md ]; then + echo "## 🛠️ Changes" >> changelog.md + echo "" >> changelog.md + cat other_issues.md >> changelog.md + echo "" >> changelog.md + fi + # 6. Build the rest of the changelog (Links & Sponsors) + cat << EOF >> changelog.md + ### 🔗 Links & Resources - [$CURRENT_TAG](https://github.com/jooby-project/jooby/tree/$CURRENT_TAG) - - [Issues](https://github.com/jooby-project/jooby/milestone/$MILESTONE_ID?closed=1) + - [Closed Issues](https://github.com/jooby-project/jooby/milestone/$MILESTONE_ID?closed=1) - [Changelog](https://github.com/jooby-project/jooby/compare/$PREV_TAG...$CURRENT_TAG) - - [Dependencies](https://github.com/jooby-project/jooby/pulls?q=is%3Apr+label%3Adependencies+is%3Aclosed+milestone%3A$VERSION_NUM) + - [Dependency Updates](https://github.com/jooby-project/jooby/pulls?q=is%3Apr+label%3Adependencies+is%3Aclosed+milestone%3A$VERSION_NUM) - ## Support my work - - [Sponsor](https://github.com/sponsors/jknack) + ## 💖 Support my work + - [Sponsor Jooby](https://github.com/sponsors/jknack) - ### Sponsors + ### 🏆 Sponsors - [@premium-minds](https://github.com/premium-minds) - [@agentgt](https://github.com/agentgt) - [@tipsy](https://github.com/tipsy) From 376cfa89365e4efe70c88a1992d9619116914cdc Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 16 Mar 2026 18:43:49 -0300 Subject: [PATCH 04/56] build: always run test in random port(0) --- .github/workflows/full-build.yml | 2 -- .github/workflows/quick-build.yml | 2 -- .../src/main/java/io/jooby/ServerOptions.java | 15 ++----------- .../main/java/io/jooby/jetty/JettyServer.java | 21 ++++++++++++++----- .../main/java/io/jooby/netty/NettyServer.java | 19 +++++++++++++++-- .../io/jooby/undertow/UndertowServer.java | 17 ++++++++++++++- .../test/java/io/jooby/i3783/Issue3783.java | 1 - .../java/io/jooby/junit/ServerTestRunner.java | 17 ++++++++------- 8 files changed, 60 insertions(+), 34 deletions(-) diff --git a/.github/workflows/full-build.yml b/.github/workflows/full-build.yml index 5b6e016b44..1bae712752 100644 --- a/.github/workflows/full-build.yml +++ b/.github/workflows/full-build.yml @@ -26,8 +26,6 @@ jobs: - name: Build run: mvn -B package -P gradlePlugin env: - BUILD_PORT: 0 - BUILD_SECURE_PORT: 0 BUILD_LOG_LEVEL: 'ERROR' - name: Tests uses: mikepenz/action-junit-report@v5 diff --git a/.github/workflows/quick-build.yml b/.github/workflows/quick-build.yml index 250134107b..bb303ed988 100644 --- a/.github/workflows/quick-build.yml +++ b/.github/workflows/quick-build.yml @@ -27,8 +27,6 @@ jobs: - name: Install run: mvn install -DskipTests -q -B env: - BUILD_PORT: 0 - BUILD_SECURE_PORT: 0 BUILD_LOG_LEVEL: 'ERROR' - name: Build run: mvn package diff --git a/jooby/src/main/java/io/jooby/ServerOptions.java b/jooby/src/main/java/io/jooby/ServerOptions.java index 7e084053f4..8022fc244d 100644 --- a/jooby/src/main/java/io/jooby/ServerOptions.java +++ b/jooby/src/main/java/io/jooby/ServerOptions.java @@ -8,8 +8,6 @@ import static java.util.Spliterators.spliteratorUnknownSize; import static java.util.stream.StreamSupport.stream; -import java.io.IOException; -import java.net.ServerSocket; import java.security.Security; import java.util.ArrayList; import java.util.Arrays; @@ -271,7 +269,7 @@ public int getPort() { * @return This options. */ public @NonNull ServerOptions setPort(int port) { - this.port = port == 0 ? randomPort() : port; + this.port = Math.max(0, port); return this; } @@ -303,7 +301,7 @@ public boolean isSSLEnabled() { if (securePort == null) { this.securePort = null; } else { - this.securePort = securePort == 0 ? randomPort() : securePort; + this.securePort = Math.max(0, securePort); } return this; } @@ -657,13 +655,4 @@ public ServerOptions setExpectContinue(@Nullable Boolean expectContinue) { } return null; } - - private static int randomPort() { - try (ServerSocket socket = new ServerSocket(0)) { - socket.setReuseAddress(true); - return socket.getLocalPort(); - } catch (IOException x) { - throw SneakyThrows.propagate(x); - } - } } diff --git a/modules/jooby-jetty/src/main/java/io/jooby/jetty/JettyServer.java b/modules/jooby-jetty/src/main/java/io/jooby/jetty/JettyServer.java index 4a55a9b6ab..9dc392a3cd 100644 --- a/modules/jooby-jetty/src/main/java/io/jooby/jetty/JettyServer.java +++ b/modules/jooby-jetty/src/main/java/io/jooby/jetty/JettyServer.java @@ -163,19 +163,21 @@ public io.jooby.Server start(@NonNull Jooby... application) { connectionFactories.addAll(http2.configure(httpConf)); } + ServerConnector httpConector = null; if (!options.isHttpsOnly()) { - var http = + httpConector = new ServerConnector( server, acceptors, selectors, connectionFactories.toArray(new ConnectionFactory[0])); - http.setPort(options.getPort()); - http.setHost(options.getHost()); + httpConector.setPort(options.getPort()); + httpConector.setHost(options.getHost()); - server.addConnector(http); + server.addConnector(httpConector); } + ServerConnector secureConnector = null; if (options.isSSLEnabled()) { var classLoader = applications.get(0).getClassLoader(); var sslContextFactory = new SslContextFactory.Server(); @@ -208,7 +210,7 @@ public io.jooby.Server start(@NonNull Jooby... application) { } secureConnectionFactories.add(new HttpConnectionFactory(httpsConf)); - var secureConnector = + secureConnector = new ServerConnector( server, acceptors, @@ -272,6 +274,15 @@ public io.jooby.Server start(@NonNull Jooby... application) { server.setHandler(context); server.start(); + // --- EXTRACT OS-ASSIGNED PORTS --- + if (httpConector != null) { + options.setPort(httpConector.getLocalPort()); + } + if (secureConnector != null) { + options.setSecurePort(secureConnector.getLocalPort()); + } + // --------------------------------- + fireReady(applications); } catch (Exception x) { if (io.jooby.Server.isAddressInUse(x.getCause())) { diff --git a/modules/jooby-netty/src/main/java/io/jooby/netty/NettyServer.java b/modules/jooby-netty/src/main/java/io/jooby/netty/NettyServer.java index d770730c67..20679d1c36 100644 --- a/modules/jooby-netty/src/main/java/io/jooby/netty/NettyServer.java +++ b/modules/jooby-netty/src/main/java/io/jooby/netty/NettyServer.java @@ -166,7 +166,15 @@ public Server start(@NonNull Jooby... application) { var http = newBootstrap( allocator, transport, newPipeline(options, null, http2, grpcProcessor), eventLoop); - http.bind(options.getHost(), options.getPort()).get(); + + var httpFuture = http.bind(options.getHost(), options.getPort()); + if (options.getPort() == 0) { + httpFuture.get(); // Wait for bind to complete + java.net.SocketAddress address = httpFuture.channel().localAddress(); + if (address instanceof java.net.InetSocketAddress) { + options.setPort(((java.net.InetSocketAddress) address).getPort()); + } + } } if (options.isSSLEnabled()) { @@ -184,7 +192,14 @@ public Server start(@NonNull Jooby... application) { newPipeline(options, sslContext, http2, grpcProcessor), eventLoop); portInUse = options.getSecurePort(); - https.bind(options.getHost(), portInUse).get(); + var httpsFuture = https.bind(options.getHost(), portInUse); + if (portInUse == 0) { + httpsFuture.get(); + var address = httpsFuture.channel().localAddress(); + if (address instanceof java.net.InetSocketAddress inetSocketAddress) { + options.setSecurePort(inetSocketAddress.getPort()); + } + } } else if (options.isHttpsOnly()) { throw new StartupException("Server configured for httpsOnly, but ssl options not set"); } diff --git a/modules/jooby-undertow/src/main/java/io/jooby/undertow/UndertowServer.java b/modules/jooby-undertow/src/main/java/io/jooby/undertow/UndertowServer.java index 8ca7addfd1..26d1840595 100644 --- a/modules/jooby-undertow/src/main/java/io/jooby/undertow/UndertowServer.java +++ b/modules/jooby-undertow/src/main/java/io/jooby/undertow/UndertowServer.java @@ -178,12 +178,27 @@ public Server start(@NonNull Jooby... application) { .ifPresent( clientAuth -> builder.setSocketOption(Options.SSL_CLIENT_AUTH_MODE, clientAuth)); } else if (options.isHttpsOnly()) { - throw new StartupException("Server configured for httpsOnly, but ssl options not set"); + throw new StartupException("Server configured for httpsOnly, but ssl options are not set"); } fireStart(applications, worker); server = builder.build(); server.start(); + // --- EXTRACT OS-ASSIGNED PORTS --- + for (var info : server.getListenerInfo()) { + var address = info.getAddress(); + if (address instanceof java.net.InetSocketAddress inetSocketAddress) { + int actualPort = inetSocketAddress.getPort(); + + if ("https".equalsIgnoreCase(info.getProtcol())) { + options.setSecurePort(actualPort); + } else { + options.setPort(actualPort); + } + } + } + // --------------------------------- + fireReady(applications); return this; diff --git a/tests/src/test/java/io/jooby/i3783/Issue3783.java b/tests/src/test/java/io/jooby/i3783/Issue3783.java index f5b7a76827..5fe6190282 100644 --- a/tests/src/test/java/io/jooby/i3783/Issue3783.java +++ b/tests/src/test/java/io/jooby/i3783/Issue3783.java @@ -13,7 +13,6 @@ import okhttp3.FormBody; public class Issue3783 { - @ServerTest public void shouldAllowToSetMaxFormFields(ServerTestRunner runner) { runner diff --git a/tests/src/test/java/io/jooby/junit/ServerTestRunner.java b/tests/src/test/java/io/jooby/junit/ServerTestRunner.java index 5f6fe53f69..90e6feaba8 100644 --- a/tests/src/test/java/io/jooby/junit/ServerTestRunner.java +++ b/tests/src/test/java/io/jooby/junit/ServerTestRunner.java @@ -103,18 +103,19 @@ public void ready(SneakyThrows.Consumer2 onReady) { // HTTP/2 is off while testing unless explicit set ServerOptions options = server.getOptions(); options.setHttp2(Optional.ofNullable(options.isHttp2()).orElse(Boolean.FALSE)); - options.setPort(Integer.parseInt(System.getenv().getOrDefault("BUILD_PORT", "9999"))); - this.allocatedPort = options.getPort(); - WebClient https; + options.setPort(0); if (options.isSSLEnabled()) { - options.setSecurePort( - Integer.parseInt(System.getenv().getOrDefault("BUILD_SECURE_PORT", "9443"))); - https = new WebClient("https", options.getSecurePort(), followRedirects); - } else { - https = null; + options.setSecurePort(0); } + WebClient https = null; try { MutedServer.mute(server).start(app); + this.allocatedPort = options.getPort(); + if (options.isSSLEnabled()) { + https = new WebClient("https", options.getSecurePort(), followRedirects); + } else { + https = null; + } if (options.isHttpsOnly()) { onReady.accept(null, https); } else { From a542a75147b16daf4f89d7758d86d7c7b19be032 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Tue, 17 Mar 2026 10:07:08 -0300 Subject: [PATCH 05/56] openapi: extract javadoc parser to his own module --- modules/jooby-javadoc/pom.xml | 174 ++++++++ .../io/jooby/internal/javadoc/ClassDoc.java | 274 +++++++++++++ .../internal/javadoc/ContentSplitter.java | 158 ++++++++ .../io/jooby/internal/javadoc/FieldDoc.java | 29 ++ .../jooby/internal/javadoc/JavaDocNode.java | 259 ++++++++++++ .../internal/javadoc/JavaDocObjectParser.java | 361 +++++++++++++++++ .../jooby/internal/javadoc/JavaDocParser.java | 338 ++++++++++++++++ .../jooby/internal/javadoc/JavaDocStream.java | 248 ++++++++++++ .../internal/javadoc/JavaDocSupport.java | 121 ++++++ .../io/jooby/internal/javadoc/JavaDocTag.java | 372 ++++++++++++++++++ .../io/jooby/internal/javadoc/MethodDoc.java | 125 ++++++ .../io/jooby/internal/javadoc/PathDoc.java | 14 + .../io/jooby/internal/javadoc/ScriptDoc.java | 42 ++ modules/pom.xml | 1 + 14 files changed, 2516 insertions(+) create mode 100644 modules/jooby-javadoc/pom.xml create mode 100644 modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/ClassDoc.java create mode 100644 modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/ContentSplitter.java create mode 100644 modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/FieldDoc.java create mode 100644 modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocNode.java create mode 100644 modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocObjectParser.java create mode 100644 modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocParser.java create mode 100644 modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocStream.java create mode 100644 modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocSupport.java create mode 100644 modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocTag.java create mode 100644 modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/MethodDoc.java create mode 100644 modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/PathDoc.java create mode 100644 modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/ScriptDoc.java diff --git a/modules/jooby-javadoc/pom.xml b/modules/jooby-javadoc/pom.xml new file mode 100644 index 0000000000..fe09d89190 --- /dev/null +++ b/modules/jooby-javadoc/pom.xml @@ -0,0 +1,174 @@ + + + + 4.0.0 + + + io.jooby + modules + 4.1.1-SNAPSHOT + + jooby-javadoc + jooby-javadoc + + + + io.jooby + jooby + ${jooby.version} + + + + + jakarta.ws.rs + jakarta.ws.rs-api + + + + jakarta.validation + jakarta.validation-api + + + + io.swagger.core.v3 + swagger-models + + + + com.puppycrawl.tools + checkstyle + 13.3.0 + + + + + org.junit.jupiter + junit-jupiter-engine + test + + + + org.apache.commons + commons-lang3 + 3.20.0 + test + + + + io.avaje + avaje-inject + 12.4 + test + + + + org.jacoco + org.jacoco.agent + runtime + test + + + + org.slf4j + slf4j-simple + ${slf4j.version} + test + + + + + io.jooby + jooby-kotlin + ${jooby.version} + test + + + + org.jetbrains.kotlin + kotlin-stdlib + test + + + + org.jetbrains.kotlin + kotlin-reflect + test + + + + org.jetbrains.kotlinx + kotlinx-coroutines-core + test + + + net.bytebuddy + byte-buddy + 1.18.7 + test + + + org.mockito + mockito-core + test + + + org.assertj + assertj-core + 3.27.7 + test + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + + + compile + none + + + test-compile + + + ${project.basedir}/src/test/kotlin + ${project.basedir}/src/test/java + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + none + + + + default-testCompile + none + + + + + + + + + + java-test-compile + + testCompile + + test-compile + + + + + + diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/ClassDoc.java b/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/ClassDoc.java new file mode 100644 index 0000000000..56abfc486e --- /dev/null +++ b/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/ClassDoc.java @@ -0,0 +1,274 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.javadoc; + +import static io.jooby.internal.javadoc.JavaDocStream.*; +import static io.jooby.internal.javadoc.JavaDocSupport.getClassName; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.puppycrawl.tools.checkstyle.DetailAstImpl; +import com.puppycrawl.tools.checkstyle.api.DetailAST; +import com.puppycrawl.tools.checkstyle.api.JavadocCommentsTokenTypes; +import com.puppycrawl.tools.checkstyle.api.TokenTypes; +import com.puppycrawl.tools.checkstyle.utils.TokenUtil; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; + +public class ClassDoc extends JavaDocNode { + private final Map fields = new LinkedHashMap<>(); + private final Map methods = new LinkedHashMap<>(); + private final Map scripts = new LinkedHashMap<>(); + private final List servers; + private final List contact; + private final List license; + private final Map securitySchemes; + + public ClassDoc(JavaDocParser ctx, DetailAST node, DetailAST javaDoc) { + super(ctx, node, javaDoc); + if (isRecord()) { + defaultRecordMembers(); + } else if (isEnum()) { + defaultEnumMembers(); + } + this.servers = JavaDocTag.servers(this.javadoc); + this.contact = JavaDocTag.contacts(this.javadoc); + this.license = JavaDocTag.license(this.javadoc); + this.securitySchemes = JavaDocTag.securitySchemes(this.javadoc); + } + + public Map getSecuritySchemes() { + return securitySchemes; + } + + public List getServers() { + return servers; + } + + public List getContact() { + return contact; + } + + public List getLicense() { + return license; + } + + public String getVersion() { + return tree(javadoc) + .filter(javadocToken(JavadocCommentsTokenTypes.VERSION_BLOCK_TAG)) + .findFirst() + .flatMap( + version -> + tree(version.getParent()) + .filter(javadocToken(JavadocCommentsTokenTypes.DESCRIPTION)) + .findFirst() + .map(it -> getText(children(it).toList(), true))) + .orElse(null); + } + + public String getEnumDescription(String text) { + if (isEnum()) { + var sb = new StringBuilder(); + var summary = Optional.ofNullable(text).orElseGet(this::getSummary); + if (summary != null) { + sb.append(summary); + } + for (Map.Entry e : fields.entrySet()) { + sb.append("\n - ").append(e.getKey()).append(": ").append(e.getValue().getText()); + } + return sb.toString().trim(); + } + return text; + } + + public String getEnumItemDescription(String name) { + if (isEnum()) { + var field = fields.get(name); + if (field != null) { + return field.getText(); + } + } + return null; + } + + private void defaultRecordMembers() { + JavaDocTag.javaDocTag( + javadoc, + tag -> { + var isParam = tree(tag).anyMatch(javadocToken(JavadocCommentsTokenTypes.PARAM_BLOCK_TAG)); + var name = + tree(tag) + .filter(javadocToken(JavadocCommentsTokenTypes.PARAMETER_NAME)) + .findFirst() + .orElse(null); + return isParam && name != null; + }, + (tag, value) -> { + var name = + tree(tag) + .filter(javadocToken(JavadocCommentsTokenTypes.PARAMETER_NAME)) + .findFirst() + .orElse(null); + // name is never null bc previous filter + Objects.requireNonNull(name, "name is null"); + /* Virtual Field */ + var memberDoc = + tree(tag) + .filter(javadocToken(JavadocCommentsTokenTypes.DESCRIPTION)) + .findFirst() + .orElse(EMPTY_NODE); + var field = + new FieldDoc( + context, createVirtualMember(name.getText(), TokenTypes.VARIABLE_DEF), memberDoc); + addField(field); + /* Virtual method */ + var method = + new MethodDoc( + context, + createVirtualMember(name.getText(), TokenTypes.METHOD_DEF), + memberDoc) + .markAsVirtual(); + + addMethod(method); + }); + } + + private void defaultEnumMembers() { + for (var constant : tree(node).filter(tokens(TokenTypes.ENUM_CONSTANT_DEF)).toList()) { + /* Virtual Field */ + var name = + tree(constant) + .filter(tokens(TokenTypes.IDENT)) + .findFirst() + .map(DetailAST::getText) + .orElseThrow(() -> new IllegalStateException("Unnamed constant: " + constant)); + var comment = + tree(constant) + .filter(tokens(TokenTypes.BLOCK_COMMENT_BEGIN)) + .findFirst() + .orElse(JavaDocNode.EMPTY_AST); + var field = + new FieldDoc(context, createVirtualMember(name, TokenTypes.VARIABLE_DEF), comment); + addField(field); + } + } + + private DetailAstImpl createVirtualMember(String name, int tokenType) { + var publicMod = new DetailAstImpl(); + publicMod.initialize( + TokenTypes.LITERAL_PUBLIC, TokenUtil.getTokenName(TokenTypes.LITERAL_PUBLIC)); + var modifiers = new DetailAstImpl(); + modifiers.initialize(TokenTypes.MODIFIERS, TokenUtil.getTokenName(tokenType)); + modifiers.addChild(publicMod); + var memberName = new DetailAstImpl(); + memberName.initialize(TokenTypes.IDENT, name); + var member = new DetailAstImpl(); + member.initialize(tokenType, TokenUtil.getTokenName(tokenType)); + memberName.addChild(modifiers); + member.addChild(memberName); + return member; + } + + public void addMethod(MethodDoc method) { + this.methods.put(toMethodSignature(method), method); + } + + public void addScript(ScriptDoc method) { + this.scripts.put(toScriptSignature(method), method); + } + + public void addField(FieldDoc field) { + this.fields.put(field.getName(), field); + } + + public Optional getField(String name) { + return Optional.ofNullable(fields.get(name)); + } + + public Optional getMethod(String name, List types) { + return Optional.ofNullable(methods.get(toMethodSignature(name, types))); + } + + public Optional getScript(String method, String pattern) { + return Optional.ofNullable(scripts.get(toScriptSignature(method, pattern))); + } + + private String toScriptSignature(ScriptDoc method) { + return toScriptSignature(method.getMethod(), method.getPattern()); + } + + private String toScriptSignature(String method, String pattern) { + return method + "/" + pattern; + } + + private String toMethodSignature(MethodDoc method) { + return toMethodSignature(method.getName(), method.getParameterTypes()); + } + + private String toMethodSignature(String methodName, List types) { + return methodName + types.stream().collect(Collectors.joining(", ", "(", ")")); + } + + public String getSimpleName() { + return JavaDocSupport.getSimpleName(node); + } + + public String getName() { + return getClassName(node); + } + + public String getPackage() { + return JavaDocSupport.getPackageName(node); + } + + public boolean isRecord() { + return tree(node).anyMatch(tokens(TokenTypes.RECORD_DEF)); + } + + public boolean isEnum() { + return tree(node).anyMatch(tokens(TokenTypes.ENUM_DEF)); + } + + private String propertyDoc(String name) { + var getterDoc = + Stream.of(name, getterName(name)) + .map(n -> methods.get(toMethodSignature(n, List.of()))) + .filter(Objects::nonNull) + .findFirst() + .map(MethodDoc::getText) + .orElse(null); + if (getterDoc == null) { + var field = fields.get(name); + return field == null ? null : field.getText(); + } + return getterDoc; + } + + public String getPropertyDoc(String name) { + var doc = propertyDoc(name); + return doc == null ? null : doc.replace(exampleCode(doc), "").trim(); + } + + public Object getPropertyExample(String name) { + return toExamples(exampleCode(propertyDoc(name))); + } + + private String getterName(String name) { + return "get" + Character.toUpperCase(name.charAt(0)) + name.substring(1); + } + + @Override + public String toString() { + return "fields: " + + String.join(", ", fields.keySet()) + + "\nmethods: " + + String.join(", ", methods.keySet()); + } +} diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/ContentSplitter.java b/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/ContentSplitter.java new file mode 100644 index 0000000000..2c956f36b7 --- /dev/null +++ b/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/ContentSplitter.java @@ -0,0 +1,158 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.javadoc; + +public class ContentSplitter { + + public record ContentResult(String summary, String description) {} + + public static ContentResult split(String text) { + if (text == null || text.isEmpty()) { + return new ContentResult("", ""); + } + + int len = text.length(); + int splitIndex = -1; + + // State trackers + int parenDepth = 0; // ( ) + int bracketDepth = 0; // [ ] + int braceDepth = 0; // { } + boolean inHtmlDef = false; // < ... > + boolean inCodeBlock = false; //
...
or ... + + for (int i = 0; i < len; i++) { + char c = text.charAt(i); + + // 1. Handle HTML Tags start + if (c == '<') { + // Check for

(Paragraph Split - Exclusive) + if (!inCodeBlock && !inHtmlDef && isTag(text, i, "p")) { + splitIndex = i; + break; + } + // Check for Protected Blocks (

, )
+        if (!inCodeBlock && (isTag(text, i, "pre") || isTag(text, i, "code"))) {
+          inCodeBlock = true;
+        }
+        // Check for end of Protected Blocks
+        if (inCodeBlock && (isCloseTag(text, i, "pre") || isCloseTag(text, i, "code"))) {
+          inCodeBlock = false;
+        }
+        inHtmlDef = true;
+        continue;
+      }
+
+      // 2. Handle HTML Tags end
+      if (c == '>') {
+        inHtmlDef = false;
+        continue;
+      }
+
+      // 3. Handle Nesting & Split
+      if (!inHtmlDef && !inCodeBlock) {
+        if (c == '(') {
+          parenDepth++;
+        } else if (c == ')') {
+          if (parenDepth > 0) parenDepth--;
+        } else if (c == '[') {
+          bracketDepth++;
+        } else if (c == ']') {
+          if (bracketDepth > 0) bracketDepth--;
+        } else if (c == '{') {
+          braceDepth++;
+        } else if (c == '}') {
+          if (braceDepth > 0) braceDepth--;
+        }
+        // 4. Check for Period
+        else if (c == '.') {
+          if (parenDepth == 0 && bracketDepth == 0 && braceDepth == 0) {
+            splitIndex = i + 1;
+            break;
+          }
+        }
+      }
+    }
+
+    String summary;
+    String description;
+
+    if (splitIndex == -1) {
+      summary = text.trim();
+      description = "";
+    } else {
+      summary = text.substring(0, splitIndex).trim();
+      description = text.substring(splitIndex).trim();
+    }
+
+    // Clean up: Strip 

tags without using Regex + return new ContentResult(stripParagraphTags(summary), stripParagraphTags(description)); + } + + /** + * Removes + * + *

and tags (and their attributes) from the text. Keeps content inside the tags. + */ + private static String stripParagraphTags(String text) { + if (text.isEmpty()) return text; + + StringBuilder sb = new StringBuilder(text.length()); + int len = text.length(); + + for (int i = 0; i < len; i++) { + char c = text.charAt(i); + + if (c == '<') { + // Detect or + if (isTag(text, i, "p") || isCloseTag(text, i, "p")) { + // Fast-forward until we find the closing '>' + while (i < len && text.charAt(i) != '>') { + i++; + } + // We are now at '>', loop increment will move past it + continue; + } + } + sb.append(c); + } + return sb.toString().trim(); + } + + // --- Helper Methods --- + + private static boolean isTag(String text, int i, String tagName) { + int len = tagName.length(); + if (i + 1 + len > text.length()) return false; + + // Match tagName (case insensitive) + for (int k = 0; k < len; k++) { + char c = text.charAt(i + 1 + k); + if (Character.toLowerCase(c) != tagName.charAt(k)) return false; + } + + // Check delimiter (must be '>' or whitespace or end of string) + if (i + 1 + len == text.length()) return true; + char delimiter = text.charAt(i + 1 + len); + return delimiter == '>' || Character.isWhitespace(delimiter); + } + + private static boolean isCloseTag(String text, int i, String tagName) { + int len = tagName.length(); + if (i + 2 + len > text.length()) return false; + if (text.charAt(i + 1) != '/') return false; + + // Match tagName (case insensitive) + for (int k = 0; k < len; k++) { + char c = text.charAt(i + 2 + k); + if (Character.toLowerCase(c) != tagName.charAt(k)) return false; + } + + // Check delimiter + char delimiter = text.charAt(i + 2 + len); + return delimiter == '>' || Character.isWhitespace(delimiter); + } +} diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/FieldDoc.java b/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/FieldDoc.java new file mode 100644 index 0000000000..abab36a2ef --- /dev/null +++ b/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/FieldDoc.java @@ -0,0 +1,29 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.javadoc; + +import com.puppycrawl.tools.checkstyle.api.DetailAST; +import com.puppycrawl.tools.checkstyle.api.DetailNode; + +public class FieldDoc extends JavaDocNode { + public FieldDoc(JavaDocParser ctx, DetailAST node, DetailAST javadoc) { + super(ctx, node, javadoc); + } + + FieldDoc(JavaDocParser ctx, DetailAST node, DetailNode javadoc) { + super(ctx, node, javadoc); + } + + @Override + public String getText() { + var text = super.getText(); + return text == null ? null : text.replace("

", "").replace("

", "").trim(); + } + + public String getName() { + return JavaDocSupport.getSimpleName(node); + } +} diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocNode.java b/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocNode.java new file mode 100644 index 0000000000..f234b4bfce --- /dev/null +++ b/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocNode.java @@ -0,0 +1,259 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.javadoc; + +import static io.jooby.internal.javadoc.JavaDocStream.javadocToken; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; + +import com.puppycrawl.tools.checkstyle.DetailNodeTreeStringPrinter; +import com.puppycrawl.tools.checkstyle.JavadocDetailNodeParser; +import com.puppycrawl.tools.checkstyle.api.DetailAST; +import com.puppycrawl.tools.checkstyle.api.DetailNode; +import com.puppycrawl.tools.checkstyle.api.JavadocCommentsTokenTypes; +import io.swagger.v3.oas.models.tags.Tag; + +public class JavaDocNode { + private static final Predicate JAVADOC_TAG = + javadocToken(JavadocCommentsTokenTypes.JAVADOC_BLOCK_TAG); + + protected final JavaDocParser context; + protected final DetailAST node; + protected final DetailNode javadoc; + private final Map extensions; + private final List tags; + + public JavaDocNode(JavaDocParser ctx, DetailAST node, DetailAST comment) { + this(ctx, node, toJavaDocNode(comment)); + } + + protected JavaDocNode(JavaDocParser ctx, DetailAST node, DetailNode javadoc) { + this.context = ctx; + this.node = node; + this.javadoc = javadoc; + this.tags = JavaDocTag.tags(javadoc); + this.extensions = JavaDocTag.extensions(javadoc); + } + + static DetailNode toJavaDocNode(DetailAST node) { + return node == EMPTY_AST + ? EMPTY_NODE + : new JavadocDetailNodeParser().parseJavadocComment(node).getTree(); + } + + public DetailAST getNode() { + return node; + } + + public Map getExtensions() { + return extensions; + } + + public String getSummary() { + var summary = ContentSplitter.split(getText()).summary(); + return summary.isEmpty() ? null : summary; + } + + public List getTags() { + return tags; + } + + public String getDescription() { + var description = ContentSplitter.split(getText()).description(); + return description.isEmpty() ? null : description; + } + + public String getText() { + return getText(JavaDocStream.forward(javadoc, JAVADOC_TAG).toList(), false); + } + + protected String exampleCode(String text) { + if (text == null) { + return ""; + } + var start = text.indexOf("`"); + if (start == -1) { + return ""; + } + var end = text.indexOf("`", start + 1); + if (end == -1) { + return ""; + } + return text.substring(start, end + 1); + } + + protected Object toExamples(String text) { + var codeExample = exampleCode(text); + if (codeExample.isEmpty()) { + return null; + } + var clean = codeExample.substring(1, codeExample.length() - 1); + var result = JavaDocObjectParser.parseJson(clean); + if (result.equals(codeExample)) { + // Like a primitive/basic example + return List.of(result); + } + return result; + } + + protected static String getText(List nodes, boolean stripLeading) { + var builder = new StringBuilder(); + var visited = new HashSet(); + for (var node : nodes) { + if (visited.add(node)) { + if (node.getType() == JavadocCommentsTokenTypes.TEXT) { + var text = node.getText(); + if (stripLeading && Character.isWhitespace(text.charAt(0))) { + builder.append(' ').append(text.stripLeading()); + } else { + builder.append(text); + } + } else if (node.getType() == JavadocCommentsTokenTypes.NEWLINE) { + var next = node.getNextSibling(); + if (next != null && next.getType() != JavadocCommentsTokenTypes.LEADING_ASTERISK) { + builder.append(next.getText()); + visited.add(next); + } + } else if (node.getType() == JavadocCommentsTokenTypes.TAG_NAME) { + //

? + if (node.getText().equals("p")) { + // keep so we can split summary from description + builder.append("

"); + } + } + } + } + return builder.isEmpty() ? null : builder.toString().trim().replaceAll("\\s+", " "); + } + + protected static String toString(DetailNode node) { + return DetailNodeTreeStringPrinter.printTree(node, "", ""); + } + + public static final DetailNode EMPTY_NODE = + new DetailNode() { + @Override + public int getType() { + return JavadocCommentsTokenTypes.JAVADOC_CONTENT; + } + + @Override + public String getText() { + return ""; + } + + @Override + public int getLineNumber() { + return 0; + } + + @Override + public int getColumnNumber() { + return 0; + } + + @Override + public DetailNode getParent() { + return null; + } + + @Override + public DetailNode getNextSibling() { + return null; + } + + @Override + public DetailNode getFirstChild() { + return null; + } + + @Override + public DetailNode getPreviousSibling() { + return null; + } + }; + + public static final DetailAST EMPTY_AST = + new DetailAST() { + @Override + public int getChildCount() { + return 0; + } + + @Override + public int getChildCount(int type) { + return 0; + } + + @Override + public DetailAST getParent() { + return null; + } + + @Override + public String getText() { + return ""; + } + + @Override + public int getType() { + return 0; + } + + @Override + public int getLineNo() { + return 0; + } + + @Override + public int getColumnNo() { + return 0; + } + + @Override + public DetailAST getLastChild() { + return null; + } + + @Override + public boolean branchContains(int type) { + return false; + } + + @Override + public DetailAST getPreviousSibling() { + return null; + } + + @Override + public DetailAST findFirstToken(int type) { + return null; + } + + @Override + public DetailAST getNextSibling() { + return null; + } + + @Override + public DetailAST getFirstChild() { + return null; + } + + @Override + public int getNumberOfChildren() { + return 0; + } + + @Override + public boolean hasChildren() { + return false; + } + }; +} diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocObjectParser.java b/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocObjectParser.java new file mode 100644 index 0000000000..8c7868582f --- /dev/null +++ b/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocObjectParser.java @@ -0,0 +1,361 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.javadoc; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * A utility class to parse a list of strings into a nested map structure. It supports multiple data + * rows, dot-separated path expressions, repeated paths, and values explicitly wrapped in square + * brackets '[]'. The structure can be augmented by subsequent rows. + */ +public class JavaDocObjectParser { + + /** + * A parser for a non-standard JSON-like syntax where string keys and values are not enclosed in + * quotation marks. + * + *

This parser supports: - Key-value pairs (e.g., key:value) - Nested objects (text wrapped + * with {}) - Arrays (text wrapped with []) - All other non-object, non-array values are parsed as + * Strings. + * + *

Note: This is a simplified parser. For standard JSON. + */ + public static class UnquotedJsonParser { + + private String text; + private int index = 0; + private static final Pattern WS = Pattern.compile("\\s"); + + /** + * Parses the given unquoted JSON-like string into a Map. + * + * @param input The string to parse. + * @return A Map representing the parsed structure. + * @throws IllegalArgumentException if the input format is invalid. + */ + public Object parse(String input) { + if (input == null || input.trim().isEmpty()) { + throw new IllegalArgumentException("Input string cannot be null or empty."); + } + this.text = input.trim(); + this.index = 0; + if (text.startsWith("{") && text.endsWith("}")) { + return parseObject(); + } else if (text.startsWith("[") && text.endsWith("]")) { + return parseArray(); + } + // generate a literal only for value without spaces + return WS.matcher(input).find() ? input : parseLiteral(); + } + + /** + * Parses an object structure, starting from the current index. An object is expected to be + * enclosed in '{' and '}'. + */ + private Map parseObject() { + Map map = new LinkedHashMap<>(); + + expectChar('{'); + skipWhitespace(); + + while (peek() != '}') { + String key = parseKey(); + skipWhitespace(); + expectChar(':'); + skipWhitespace(); + Object value = parseValue(); + map.put(key, value); + skipWhitespace(); + + if (peek() == ',') { + consumeChar(); + skipWhitespace(); + } else if (peek() != '}') { + throw new IllegalArgumentException("Expected ',' or '}' after value at index " + index); + } + } + + expectChar('}'); + return map; + } + + /** + * Parses an array structure, starting from the current index. An array is expected to be + * enclosed in '[' and ']'. + */ + private List parseArray() { + List list = new ArrayList<>(); + + expectChar('['); + skipWhitespace(); + + while (peek() != ']') { + list.add(parseValue()); + skipWhitespace(); + + if (peek() == ',') { + consumeChar(); + skipWhitespace(); + } else if (peek() != ']') { + throw new IllegalArgumentException( + "Expected ',' or ']' after value in array at index " + index); + } + } + + expectChar(']'); + return list; + } + + /** + * Parses a key from the input string. A key is a sequence of characters up to the colon ':'. + */ + private String parseKey() { + int start = index; + while (index < text.length() && text.charAt(index) != ':') { + index++; + } + if (index == start) { + throw new IllegalArgumentException("Found empty key at index " + start); + } + return text.substring(start, index).trim(); + } + + /** + * Determines the type of the value at the current index and parses it. It can be an object, an + * array, or a string literal. + */ + private Object parseValue() { + skipWhitespace(); + char currentChar = peek(); + + if (currentChar == '{') { + return parseObject(); + } else if (currentChar == '[') { + return parseArray(); + } else { + return parseLiteral(); + } + } + + /** + * Parses a literal value as a string. The literal ends at the next comma ',', closing brace + * '}', or closing bracket ']'. + */ + private Object parseLiteral() { + int start = index; + while (index < text.length() + && text.charAt(index) != ',' + && text.charAt(index) != '}' + && text.charAt(index) != ']') { + index++; + } + var literal = text.substring(start, index).trim(); + try { + return Long.parseLong(literal); + } catch (NumberFormatException ignored) { + try { + return Double.parseDouble(literal); + } catch (NumberFormatException ignored2) { + return "true".equals(literal) + ? Boolean.TRUE + : "false".equals(literal) ? Boolean.FALSE : literal; + } + } + } + + // --- Utility Methods --- + + private char peek() { + if (index >= text.length()) { + throw new IllegalArgumentException("Unexpected end of input."); + } + return text.charAt(index); + } + + private void consumeChar() { + index++; + } + + private void expectChar(char expected) { + skipWhitespace(); + if (peek() != expected) { + throw new IllegalArgumentException( + "Expected '" + expected + "' but found '" + peek() + "' at index " + index); + } + consumeChar(); + } + + private void skipWhitespace() { + while (index < text.length() && Character.isWhitespace(text.charAt(index))) { + index++; + } + } + } + + /** + * Parses a list containing one or more data rows into a list of maps. - A new row is started each + * time the first key of the input list is encountered. - Subsequent rows can add new keys + * (augment the structure) but cannot change the data type of an existing path (e.g., from an + * object to a string). - Repeated paths within a single row will generate a list of values. - A + * value wrapped in '[]' (e.g., "[value]") will be parsed as a list with a single element. + * + * @param inputList The list of strings to parse. Must not be null. + * @return A List of Maps, where each map represents a data row. + * @throws IllegalArgumentException if a row tries to change an existing data structure. + */ + public static List> parse(List inputList) { + try { + if (inputList.isEmpty()) { + return List.of(); + } + + // --- Step 1: Split the flat input list into a list of rows --- + List> allRowsPairs = new ArrayList<>(); + String rowDelimiterKey = inputList.get(0); + List currentRowPairs = new ArrayList<>(); + + for (int i = 0; i < inputList.size(); i += 2) { + String key = inputList.get(i); + if (i + 1 >= inputList.size()) { + break; // Ignore last key if it has no value + } + String value = inputList.get(i + 1); + + if (key.equals(rowDelimiterKey) && !currentRowPairs.isEmpty()) { + allRowsPairs.add(new ArrayList<>(currentRowPairs)); + currentRowPairs.clear(); + } + currentRowPairs.add(key); + currentRowPairs.add(value); + } + if (!currentRowPairs.isEmpty()) { + allRowsPairs.add(new ArrayList<>(currentRowPairs)); + } + + // --- Step 2: Process each row independently and add its map to the result list --- + List> resultList = new ArrayList<>(); + for (List rowPairs : allRowsPairs) { + resultList.add(buildMapFromPairs(rowPairs)); + } + + return resultList; + } catch (Exception ignored) { + // just returns empty, we won't fail the entire process. + return List.of(); + } + } + + /** + * Builds a single nested map from a list of key-value pairs representing one row. This helper + * contains the core parsing logic for a single data row. + * + * @param pairs A list of strings for a single data row. + * @return A single, potentially nested, map. + */ + private static Map buildMapFromPairs(List pairs) { + Map resultMap = new LinkedHashMap<>(); + + // Step 1: Group all values by their full path. + Map> pathValues = new LinkedHashMap<>(); + for (int i = 0; i < pairs.size(); i += 2) { + pathValues.computeIfAbsent(pairs.get(i), k -> new ArrayList<>()).add(pairs.get(i + 1)); + } + + // Step 2: Group paths by their first segment (e.g., "parents" from "parents.name"). + Map> groupedByFirstSegment = new LinkedHashMap<>(); + for (String path : pathValues.keySet()) { + String firstSegment = path.split("\\.")[0]; + groupedByFirstSegment.computeIfAbsent(firstSegment, k -> new ArrayList<>()).add(path); + } + + // Step 3: Build the map by processing each group. + for (Map.Entry> entry : groupedByFirstSegment.entrySet()) { + String groupKey = entry.getKey(); + List pathsInGroup = entry.getValue(); + + boolean isList = pathsInGroup.stream().anyMatch(p -> pathValues.get(p).size() > 1); + boolean isSimpleList = pathsInGroup.size() == 1 && pathsInGroup.get(0).equals(groupKey); + + if (isList && isSimpleList) { + // Case 1: A list of simple values (e.g., "tags": ["a", "b"]). + List values = pathValues.get(groupKey); + resultMap.put(groupKey, values); + } else if (isList) { + // Case 2: A list of nested objects (e.g., "parents": [{...}, {...}]). + int rowCount = pathValues.get(pathsInGroup.get(0)).size(); + List> listOfMaps = new ArrayList<>(); + for (int i = 0; i < rowCount; i++) { + listOfMaps.add(new LinkedHashMap<>()); + } + + for (String path : pathsInGroup) { + List values = pathValues.get(path); + if (values.size() != rowCount) { + throw new IllegalArgumentException( + "Mismatched value count for properties of '" + groupKey + "'."); + } + String innerPath = path.substring(groupKey.length() + 1); + for (int i = 0; i < rowCount; i++) { + setValue(listOfMaps.get(i), innerPath, values.get(i)); + } + } + resultMap.put(groupKey, listOfMaps); + } else { + // Case 3: Simple key-value pairs, possibly nested (e.g., "address.country"). + for (String path : pathsInGroup) { + String value = pathValues.get(path).get(0); + setValue(resultMap, path, parseJson(value)); + } + } + } + return resultMap; + } + + public static Object parseJson(String value) { + try { + return new UnquotedJsonParser().parse(value); + } catch (Exception ignored) { + // just returns the input value, we won't fail the entire process + return value; + } + } + + /** + * A helper method to place a value into a nested map structure based on a dot-separated path. It + * creates nested maps as needed and throws an exception on path conflicts. + * + * @param map The root map to modify. + * @param path The dot-separated path (e.g., "address.country"). + * @param value The value to set at the specified path. + */ + @SuppressWarnings("unchecked") + private static void setValue(Map map, String path, Object value) { + String[] parts = path.split("\\."); + Map currentMap = map; + + for (int i = 0; i < parts.length - 1; i++) { + String part = parts[i]; + Object node = currentMap.computeIfAbsent(part, k -> new LinkedHashMap()); + + if (!(node instanceof Map)) { + throw new IllegalArgumentException( + "Path conflict: '" + part + "' contains a value and cannot be treated as a map."); + } + currentMap = (Map) node; + } + + String finalKey = parts[parts.length - 1]; + if (!currentMap.containsKey(finalKey)) { + // Don't override + currentMap.put(finalKey, value); + } + } +} diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocParser.java b/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocParser.java new file mode 100644 index 0000000000..35b6823011 --- /dev/null +++ b/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocParser.java @@ -0,0 +1,338 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.javadoc; + +import static com.puppycrawl.tools.checkstyle.JavaParser.parseFile; +import static io.jooby.SneakyThrows.throwingFunction; +import static io.jooby.internal.javadoc.JavaDocStream.*; +import static io.jooby.internal.javadoc.JavaDocStream.tokens; +import static io.jooby.internal.javadoc.JavaDocSupport.*; +import static java.util.Optional.ofNullable; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; +import java.util.function.Predicate; + +import com.puppycrawl.tools.checkstyle.JavaParser; +import com.puppycrawl.tools.checkstyle.api.DetailAST; +import com.puppycrawl.tools.checkstyle.api.TokenTypes; +import com.puppycrawl.tools.checkstyle.utils.TokenUtil; +import com.puppycrawl.tools.checkstyle.utils.XpathUtil; +import io.jooby.Context; +import io.jooby.Router; + +public class JavaDocParser { + + public static final JavaDocParser NOOP = new JavaDocParser(List.of()); + + private record ScriptRef(String operationId, DetailAST comment) {} + + private final List baseDir; + private final Map cache = new HashMap<>(); + + public JavaDocParser(Path baseDir) { + this(List.of(baseDir)); + } + + public JavaDocParser(List baseDir) { + this.baseDir = baseDir; + } + + public Optional parse(String typeName) { + return ofNullable(traverse(resolveType(typeName)).get(typeName)); + } + + public DetailAST resolve(Path path) { + return lookup(path) + .map( + it -> + cache.computeIfAbsent( + it, + throwingFunction( + filePath -> { + return parseFile(filePath.toFile(), JavaParser.Options.WITH_COMMENTS); + }))) + .orElse(JavaDocNode.EMPTY_AST); + } + + private Map traverse(DetailAST tree) { + var classes = new HashMap(); + traverse( + List.of(tree), + TYPES, + modifiers -> tree(modifiers).noneMatch(tokens(TokenTypes.LITERAL_PRIVATE)), + (scope, comment) -> { + var scopes = typeHierarchy(scope); + var counter = new AtomicInteger(0); + counter.addAndGet(comment == JavaDocNode.EMPTY_AST ? 0 : 1); + var classDoc = new ClassDoc(this, scope, comment); + + // MVC routes + traverse( + scopes, + tokens(TokenTypes.VARIABLE_DEF, TokenTypes.METHOD_DEF), + modifiers -> tree(modifiers).noneMatch(tokens(TokenTypes.LITERAL_STATIC)), + (member, memberComment) -> { + counter.addAndGet(memberComment == JavaDocNode.EMPTY_AST ? 0 : 1); + // check member belong to current scope + if (backward(member) + .filter(TYPES) + .findFirst() + .filter(scopes::contains) + .isPresent()) { + if (member.getType() == TokenTypes.VARIABLE_DEF) { + classDoc.addField(new FieldDoc(this, member, memberComment)); + } else { + classDoc.addMethod(new MethodDoc(this, member, memberComment)); + } + } + }); + // Script routes + counter.addAndGet(scripts(scope, classDoc, null, null, new HashSet<>())); + + if (counter.get() > 0) { + classes.put(classDoc.getName(), classDoc); + } + }); + return classes; + } + + private void traverse( + List scopes, + Predicate types, + Predicate modifiers, + BiConsumer action) { + + for (var scope : scopes) { + for (var node : tree(scope).filter(types).toList()) { + var mods = + tree(node) + .filter(tokens(TokenTypes.MODIFIERS)) + .findFirst() + .orElseThrow( + () -> + new IllegalArgumentException( + "Modifiers not found on " + TokenUtil.getTokenName(node.getType()))); + if (modifiers.test(mods)) { + var docRoot = node.getType() == TokenTypes.VARIABLE_DEF ? mods.getParent() : mods; + var comment = + tree(docRoot) + .filter(tokens(TokenTypes.BLOCK_COMMENT_BEGIN)) + .findFirst() + .orElse(JavaDocNode.EMPTY_AST); + action.accept(node, comment); + } + } + } + } + + private List typeHierarchy(DetailAST scope) { + var result = new ArrayList(); + // call parent members first, so we can inherit and override later. + children(scope) + .filter(tokens(TokenTypes.EXTENDS_CLAUSE)) + .findFirst() + .map(it -> JavaDocSupport.toQualifiedName(scope, getQualifiedName(it))) + .flatMap( + superClass -> + tree(resolveType(superClass)) + .filter(TYPES) + .filter(it -> superClass.endsWith("." + getSimpleName(it))) + .findFirst()) + .ifPresent(parent -> result.addAll(typeHierarchy(parent))); + result.add(scope); + return result; + } + + private int scripts( + DetailAST scope, ClassDoc classDoc, PathDoc pathDoc, String prefix, Set visited) { + var counter = new AtomicInteger(0); + for (var script : tree(scope).filter(tokens(TokenTypes.METHOD_CALL)).toList()) { + if (visited.add(script)) { + // Test for HTTP method name + var callName = + tree(script) + .filter(tokens(TokenTypes.IDENT)) + .findFirst() + .map(DetailAST::getText) + .stream() + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("No method call found: " + script)); + var scriptComment = + children(script) + .filter(tokens(TokenTypes.BLOCK_COMMENT_BEGIN)) + .findFirst() + .orElse(JavaDocNode.EMPTY_AST); + if (scriptComment != JavaDocNode.EMPTY_AST) { + counter.incrementAndGet(); + } + if (Router.METHODS.contains(callName.toUpperCase())) { + pathLiteral(script) + .ifPresent( + pattern -> { + var resolvedComment = resolveScriptComment(classDoc, script, scriptComment); + if (scriptComment != resolvedComment.comment) { + counter.incrementAndGet(); + } + var scriptDoc = + new ScriptDoc( + this, + callName.toUpperCase(), + computePath(prefix, pattern), + script, + resolvedComment.comment); + if (resolvedComment.operationId() != null) { + scriptDoc.setOperationId(resolvedComment.operationId()); + } + scriptDoc.setPath(pathDoc); + classDoc.addScript(scriptDoc); + }); + } else if ("path".equals(callName)) { + pathLiteral(script) + .ifPresent( + path -> { + scripts( + script, + classDoc, + new PathDoc(this, script, scriptComment), + computePath(prefix, path), + visited); + }); + } + } + } + return counter.get(); + } + + /** + * get("/reference", this::findPetById); post("/static-reference", + * javadoc.input.LambdaRefApp::staticFindPetById); put("/external-reference", + * RequestHandler::external); get("/external-subPackage-reference", + * SubPackageHandler::subPackage); + * + * @param classDoc + * @param script + * @param defaultComment + * @return + */ + private ScriptRef resolveScriptComment( + ClassDoc classDoc, DetailAST script, DetailAST defaultComment) { + // ELIST -> LAMBDA (children) + // ELIST -> EXPR -> METHOD_REF (tree) + return children(script) + .filter(tokens(TokenTypes.ELIST)) + .findFirst() + .map( + statementList -> + children(statementList) + .filter(tokens(TokenTypes.LAMBDA)) + .findFirst() + .map(lambda -> new ScriptRef(null, defaultComment)) + .orElseGet( + () -> + tree(statementList) + .filter(tokens(TokenTypes.METHOD_REF)) + .findFirst() + .flatMap(ref -> ofNullable(resolveFromMethodRef(classDoc, ref))) + .orElseGet(() -> new ScriptRef(null, defaultComment)))) + .orElseGet(() -> new ScriptRef(null, defaultComment)); + } + + private ScriptRef resolveFromMethodRef(ClassDoc classDoc, DetailAST methodRef) { + var referenceOwner = getQualifiedName(methodRef); + DetailAST scope = null; + String className; + if (referenceOwner.equals("this")) { + scope = classDoc.getNode(); + className = classDoc.getName(); + } else { + // resolve className + className = toQualifiedName(classDoc.node, referenceOwner); + scope = resolveType(className); + if (scope == JavaDocNode.EMPTY_AST) { + // not found + return null; + } + } + var methodName = + children(methodRef).filter(tokens(TokenTypes.IDENT)).toList().getLast().getText(); + var method = + tree(scope) + .filter(tokens(TokenTypes.METHOD_DEF)) + .filter( + it -> + children(it) + .filter(tokens(TokenTypes.IDENT)) + .findFirst() + .filter(e -> e.getText().equals(methodName)) + .isPresent()) + // One Argument + .filter(it -> tree(it).filter(tokens(TokenTypes.PARAMETER_DEF)).count() == 1) + // Context Type + .filter( + it -> + tree(it) + .filter(tokens(TokenTypes.PARAMETER_DEF)) + .findFirst() + .flatMap(p -> children(p).filter(tokens(TokenTypes.TYPE)).findFirst()) + .filter( + type -> + toQualifiedName(classDoc.node, getQualifiedName(type)) + .equals(Context.class.getName())) + .isPresent()) + .findFirst() + .orElseThrow( + () -> + new IllegalArgumentException( + "No method found: " + className + "." + methodName)); + return children(method) + .filter(tokens(TokenTypes.MODIFIERS)) + .findFirst() + .flatMap(it -> children(it).filter(tokens(TokenTypes.BLOCK_COMMENT_BEGIN)).findFirst()) + .map(comment -> new ScriptRef(methodName, comment)) + .orElseGet(() -> new ScriptRef(null, JavaDocNode.EMPTY_AST)); + } + + /** + * ELIST -> EXPR -> STRING_LITERAL + * + * @param script Get string literal from method call. + * @return String literal. + */ + private static Optional pathLiteral(DetailAST script) { + return children(script) + .filter(tokens(TokenTypes.ELIST)) + .findFirst() + .flatMap(it -> children(it).filter(tokens(TokenTypes.EXPR)).findFirst()) + .flatMap(it -> children(it).filter(tokens(TokenTypes.STRING_LITERAL)).findFirst()) + .map(XpathUtil::getTextAttributeValue); + } + + private String computePath(String prefix, String pattern) { + if (prefix == null) { + return Router.normalizePath(pattern); + } + return Router.noTrailingSlash(Router.normalizePath(prefix + pattern)); + } + + private DetailAST resolveType(String typeName) { + var segments = typeName.split("\\."); + segments[segments.length - 1] = segments[segments.length - 1] + ".java"; + return resolve(Paths.get(String.join(File.separator, segments))); + } + + private Optional lookup(Path path) { + return baseDir.stream() + .map(parentDir -> parentDir.resolve(path)) + .filter(Files::exists) + .findFirst(); + } +} diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocStream.java b/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocStream.java new file mode 100644 index 0000000000..f6007a923c --- /dev/null +++ b/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocStream.java @@ -0,0 +1,248 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.javadoc; + +import java.util.*; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +import com.puppycrawl.tools.checkstyle.api.DetailAST; +import com.puppycrawl.tools.checkstyle.api.DetailNode; + +public final class JavaDocStream { + + public static Predicate tokens(Integer... types) { + return tokens(Set.of(types)); + } + + private static Predicate tokens(Set types) { + return it -> types.contains(it.getType()); + } + + public static Predicate javadocToken(Integer... types) { + return javadocToken(Set.of(types)); + } + + private static Predicate javadocToken(Set types) { + return it -> types.contains(it.getType()); + } + + /** + * Traverse the tree from current node to parent (backward). + * + * @param node Starting point + * @return Stream. + */ + public static Stream backward(DetailAST node) { + return backward(ASTNode.ast(node)); + } + + /** + * Traverse the tree from the current node to children and sibling (forward). + * + * @param node Starting point + * @return Stream. + */ + public static Stream forward(DetailAST node) { + return forward(ASTNode.ast(node)); + } + + /** + * Traverse the tree from the current node to children and sibling (forward) but keeping the scope + * to the given node (root). + * + * @param node Root node. + * @return Stream. + */ + public static Stream tree(DetailAST node) { + return tree(ASTNode.ast(node)); + } + + public static Stream tree(DetailNode node) { + return tree(ASTNode.javadoc(node)); + } + + public static Stream children(DetailAST node) { + return stream(childrenIterator(ASTNode.ast(node))); + } + + public static Stream children(DetailNode node) { + return stream(childrenIterator(ASTNode.javadoc(node))); + } + + public static Stream backward(DetailNode node) { + return backward(ASTNode.javadoc(node)); + } + + public static Stream forward(DetailNode node, Predicate stopOn) { + var nodes = forward(ASTNode.javadoc(node)).toList(); + var result = new ArrayList(); + for (var it : nodes) { + if (stopOn.test(it)) { + break; + } + result.add(it); + } + return result.stream(); + } + + private static Stream backward(ASTNode node) { + return stream(backwardIterator(node)); + } + + private static Stream forward(ASTNode node) { + return stream(forwardIterator(node)); + } + + private static Stream tree(ASTNode node) { + return stream(treeIterator(node)); + } + + private static Stream stream(Iterator iterator) { + return StreamSupport.stream( + Spliterators.spliteratorUnknownSize(iterator, Spliterator.ORDERED), false); + } + + private static Iterator treeIterator(ASTNode node) { + return forwardIterator(node, false); + } + + private static Iterator forwardIterator(ASTNode node) { + return forwardIterator(node, true); + } + + private static Iterator childrenIterator(ASTNode node) { + return new Iterator<>() { + Function, ASTNode> direction = null; + ASTNode it = node; + + @Override + public boolean hasNext() { + if (direction == null) { + direction = ASTNode::getFirstChild; + } else { + direction = ASTNode::getNextSibling; + } + return direction.apply(it) != null; + } + + @Override + public T next() { + it = direction.apply(it); + return it.getNode(); + } + }; + } + + private static Iterator backwardIterator(ASTNode node) { + return new Iterator<>() { + ASTNode it = node; + + @Override + public boolean hasNext() { + return it.getNode() != null && it.getParent() != null; + } + + @Override + public T next() { + it = it.getParent(); + return it.getNode(); + } + }; + } + + private static Iterator forwardIterator(ASTNode node, boolean full) { + return new Iterator<>() { + ASTNode it = node; + final Stack> stack = new Stack<>(); + + @Override + public boolean hasNext() { + return it != null && it.getNode() != null; + } + + @Override + public T next() { + var sibling = it.getNextSibling(); + if (sibling != null) { + if (full || it != node) { + stack.push(sibling); + } + } + var current = it; + var child = it.getFirstChild(); + if (child == null) { + if (!stack.isEmpty()) { + it = stack.pop(); + } else { + it = null; + } + } else { + it = child; + } + return current.getNode(); + } + }; + } + + private interface ASTNode { + ASTNode getFirstChild(); + + ASTNode getNextSibling(); + + ASTNode getParent(); + + Node getNode(); + + static ASTNode ast(DetailAST node) { + return ast(node, DetailAST::getParent, DetailAST::getFirstChild, DetailAST::getNextSibling); + } + + static ASTNode javadoc(DetailNode node) { + return ast( + node, DetailNode::getParent, DetailNode::getFirstChild, DetailNode::getNextSibling); + } + + static ASTNode ast( + N node, Function parent, Function child, Function sibling) { + return new ASTNode<>() { + + private ASTNode apply(Function fn) { + if (node == null) { + return null; + } + var result = fn.apply(node); + if (result == null) { + return null; + } + return ast(result, parent, child, sibling); + } + + @Override + public ASTNode getParent() { + return apply(parent); + } + + @Override + public ASTNode getFirstChild() { + return apply(child); + } + + @Override + public N getNode() { + return node; + } + + @Override + public ASTNode getNextSibling() { + return apply(sibling); + } + }; + } + } +} diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocSupport.java b/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocSupport.java new file mode 100644 index 0000000000..bf18f08ed6 --- /dev/null +++ b/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocSupport.java @@ -0,0 +1,121 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.javadoc; + +import static io.jooby.internal.javadoc.JavaDocStream.*; + +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.puppycrawl.tools.checkstyle.api.DetailAST; +import com.puppycrawl.tools.checkstyle.api.TokenTypes; +import com.puppycrawl.tools.checkstyle.utils.TokenUtil; + +public class JavaDocSupport { + + public static final Predicate TYPES = + tokens( + TokenTypes.CLASS_DEF, + TokenTypes.INTERFACE_DEF, + TokenTypes.ENUM_DEF, + TokenTypes.RECORD_DEF); + + public static final Predicate EXTENDED_TYPES = + TYPES.or(tokens(TokenTypes.VARIABLE_DEF, TokenTypes.PARAMETER_DEF)); + + /** + * Name from class, method, field, parameter. + * + * @param node Node. + * @return Name. + */ + public static String getSimpleName(DetailAST node) { + checkTypeDef(EXTENDED_TYPES, node); + return node.findFirstToken(TokenTypes.IDENT).getText(); + } + + public static String getClassName(DetailAST node) { + checkTypeDef(TYPES, node); + var classScope = + Stream.concat(Stream.of(node), backward(node).filter(TYPES)) + .map(JavaDocSupport::getSimpleName) + .toList(); + + return Stream.concat(Stream.of(getPackageName(node)), classScope.stream()) + .collect(Collectors.joining(".")); + } + + public static DetailAST getCompilationUnit(DetailAST node) { + return backward(node) + .filter(tokens(TokenTypes.COMPILATION_UNIT)) + .findFirst() + .orElseThrow( + () -> + new IllegalArgumentException( + "Compilation unit missing: " + TokenUtil.getTokenName(node.getType()))); + } + + public static String getPackageName(DetailAST node) { + return String.join( + ".", + tree(getCompilationUnit(node)) + .filter(tokens(TokenTypes.PACKAGE_DEF)) + .findFirst() + .map(it -> tree(it).filter(tokens(TokenTypes.IDENT)).map(DetailAST::getText).toList()) + .orElse(List.of())); + } + + public static String getQualifiedName(DetailAST node) { + return tree(node.getFirstChild()) + .filter(tokens(TokenTypes.DOT).negate()) + .map(DetailAST::getText) + .collect(Collectors.joining(".")); + } + + public static String toQualifiedName(DetailAST classDef, String typeName) { + return switch (typeName) { + case "char", "boolean", "int", "short", "long", "float", "double" -> typeName; + case "Character" -> Character.class.getName(); + case "Boolean" -> Boolean.class.getName(); + case "Integer" -> Integer.class.getName(); + case "Short" -> Short.class.getName(); + case "Long" -> Long.class.getName(); + case "Float" -> Float.class.getName(); + case "Double" -> Double.class.getName(); + case "String" -> String.class.getName(); + default -> { + checkTypeDef(TYPES, classDef); + if (!typeName.contains(".")) { + if (!getSimpleName(classDef).equals(typeName)) { + var cu = getCompilationUnit(classDef); + yield children(cu) + .filter(tokens(TokenTypes.IMPORT)) + .map( + it -> + tree(it.getFirstChild()) + .filter(tokens(TokenTypes.DOT).negate()) + .map(DetailAST::getText) + .collect(Collectors.joining("."))) + .filter(qualifiedName -> qualifiedName.endsWith("." + typeName)) + .findFirst() + .orElseGet(() -> String.join(".", getPackageName(classDef), typeName)); + } + } + // Already qualified. + yield typeName; + } + }; + } + + private static void checkTypeDef(Predicate predicate, DetailAST node) { + if (!predicate.test(node)) { + throw new IllegalArgumentException( + "Must be a type definition, found: " + TokenUtil.getTokenName(node.getType())); + } + } +} diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocTag.java b/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocTag.java new file mode 100644 index 0000000000..fd06c65309 --- /dev/null +++ b/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocTag.java @@ -0,0 +1,372 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.javadoc; + +import static io.jooby.internal.javadoc.JavaDocNode.getText; +import static io.jooby.internal.javadoc.JavaDocStream.*; +import static io.jooby.internal.javadoc.JavaDocStream.children; +import static java.util.Optional.ofNullable; + +import java.util.*; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.puppycrawl.tools.checkstyle.api.DetailNode; +import com.puppycrawl.tools.checkstyle.api.JavadocCommentsTokenTypes; +import io.jooby.SneakyThrows.Consumer2; +import io.jooby.SneakyThrows.Consumer3; +import io.jooby.StatusCode; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.security.*; +import io.swagger.v3.oas.models.servers.Server; +import io.swagger.v3.oas.models.tags.Tag; + +public class JavaDocTag { + private static final Predicate TAG_SHORT = it -> it.getText().equals("tag"); + private static final Predicate TAG = it -> it.getText().startsWith("tag."); + private static final Predicate SERVER = it -> it.getText().startsWith("server."); + private static final Predicate SECURITY = + it -> it.getText().equals("security") || it.getText().equals("securityRequirement"); + private static final Predicate SECURITY_SCHEME = + it -> it.getText().startsWith("securityScheme."); + private static final Predicate CONTACT = it -> it.getText().startsWith("contact."); + private static final Predicate LICENSE = it -> it.getText().startsWith("license."); + private static final Predicate OPERATION_ID = + it -> it.getText().equals("operationId"); + private static final Predicate EXTENSION = it -> it.getText().startsWith("x-"); + private static final Predicate THROWS = + it -> tree(it).anyMatch(javadocToken(JavadocCommentsTokenTypes.THROWS_BLOCK_TAG)); + private static final Predicate PARAM = + it -> tree(it).anyMatch(javadocToken(JavadocCommentsTokenTypes.PARAM_BLOCK_TAG)); + + public static Map getParametersDoc(DetailNode node) { + var parameters = new LinkedHashMap(); + javaDocTag( + node, + PARAM, + (tag, value) -> { + tree(tag) + .filter(javadocToken(JavadocCommentsTokenTypes.PARAMETER_NAME)) + .findFirst() + .map(DetailNode::getText) + .ifPresent( + name -> + tree(tag) + .filter(javadocToken(JavadocCommentsTokenTypes.DESCRIPTION)) + .findFirst() + .map(description -> JavaDocNode.getText(tree(description).toList(), true)) + .ifPresent(text -> parameters.put(name, text))); + }); + return parameters; + } + + public static String getReturnDoc(DetailNode node) { + var text = new StringBuilder(); + javaDocTag( + node, + javadocToken(JavadocCommentsTokenTypes.RETURN_BLOCK_TAG), + (tag, value) -> { + tree(tag.getParent()) + .filter(javadocToken(JavadocCommentsTokenTypes.DESCRIPTION)) + .findFirst() + .map(description -> JavaDocNode.getText(tree(description).toList(), true)) + .ifPresent(text::append); + }); + var result = text.toString().trim(); + return result.isEmpty() ? null : result; + } + + public static List securityRequirement(DetailNode node) { + return parse(node, SECURITY, null).stream() + .map( + hash -> + hash.containsKey("security") + ? hash.get("security") + : hash.get("securityRequirement")) + .map(Object::toString) + .map(String::trim) + .map( + value -> { + // look first space + var indexOf = value.indexOf(' '); + String key; + String scopes; + if (indexOf > 0) { + key = value.substring(0, indexOf).trim(); + scopes = value.substring(indexOf + 1).trim(); + } else { + key = value; + scopes = ""; + } + if (scopes.startsWith("[") && scopes.endsWith("]")) { + scopes = scopes.substring(1, scopes.length() - 1); + } + var scopeList = Stream.of(scopes.split(",")).map(String::trim).toList(); + var security = new SecurityRequirement(); + security.addList(key, scopeList); + return security; + }) + .toList(); + } + + public static Map securitySchemes(DetailNode node) { + var result = new LinkedHashMap(); + parse(node, SECURITY_SCHEME, "securityScheme") + .forEach( + hash -> { + var item = new SecurityScheme(); + item.setDescription((String) hash.get("description")); + var name = (String) hash.get("name"); + item.setName((String) hash.get("paramName")); + ofNullable((String) hash.get("in")) + .map(String::toUpperCase) + .map(SecurityScheme.In::valueOf) + .ifPresent(item::setIn); + ofNullable((String) hash.get("type")) + .map(String::toUpperCase) + .map(SecurityScheme.Type::valueOf) + .ifPresent(item::setType); + item.setBearerFormat((String) hash.get("bearerFormat")); + item.setOpenIdConnectUrl((String) hash.get("openIdConnectUrl")); + item.setScheme((String) hash.get("scheme")); + var objectFlows = hash.get("flows"); + if (objectFlows instanceof Map hashFlows) { + OAuthFlows flows = new OAuthFlows(); + toOauthFlow("implicit", hashFlows, flows::setImplicit); + toOauthFlow("password", hashFlows, flows::setPassword); + toOauthFlow("authorizationCode", hashFlows, flows::setAuthorizationCode); + toOauthFlow("clientCredentials", hashFlows, flows::setClientCredentials); + item.setFlows(flows); + } + result.put(name, item); + }); + return result; + } + + private static void toOauthFlow(String path, Map flows, Consumer consumer) { + var flowHash = flows.get(path); + if (flowHash instanceof Map hash) { + var oauthFlow = new OAuthFlow(); + oauthFlow.setAuthorizationUrl((String) hash.get("authorizationUrl")); + oauthFlow.setTokenUrl((String) hash.get("tokenUrl")); + oauthFlow.setRefreshUrl((String) hash.get("refreshUrl")); + var scopesObject = hash.get("scopes"); + List scopeNames; + List scopeDescriptions; + if (scopesObject instanceof Map scopesHash) { + scopeNames = ensureList(scopesHash.get("name")); + scopeDescriptions = ensureList(scopesHash.get("description")); + } else { + scopeNames = ensureList(scopesObject); + scopeDescriptions = List.of(); + } + if (!scopeNames.isEmpty()) { + Scopes scopes = new Scopes(); + for (int i = 0; i < scopeNames.size(); i++) { + var description = i < scopeDescriptions.size() ? scopeDescriptions.get(i) : ""; + scopes.addString(scopeNames.get(i), description); + } + oauthFlow.setScopes(scopes); + } + consumer.accept(oauthFlow); + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static List ensureList(Object value) { + if (value == null) return List.of(); + if (value instanceof List list) { + return list.stream().map(Objects::toString).toList(); + } + return List.of(value.toString()); + } + + public static List servers(DetailNode node) { + return parse(node, SERVER, "server").stream() + .map( + hash -> { + var server = new Server(); + server.setDescription((String) hash.get("description")); + server.setUrl((String) hash.get("url")); + return server; + }) + .toList(); + } + + public static List contacts(DetailNode node) { + return parse(node, CONTACT, "contact").stream() + .map( + hash -> { + var item = new Contact(); + item.setName((String) hash.get("name")); + item.setUrl((String) hash.get("url")); + item.setEmail((String) hash.get("email")); + return item; + }) + .toList(); + } + + public static List license(DetailNode node) { + return parse(node, LICENSE, "license").stream() + .map( + hash -> { + var item = new License(); + item.setName((String) hash.get("name")); + item.setIdentifier((String) hash.get("identifier")); + item.setUrl((String) hash.get("url")); + return item; + }) + .toList(); + } + + private static List> parse( + DetailNode node, Predicate filter, String path) { + var values = new ArrayList(); + javaDocTag( + node, + filter, + (tag, value) -> { + values.add(tag.getText()); // .substring(1)); + values.add(value); + }); + return JavaDocObjectParser.parse(values).stream() + .map(hash -> path == null ? hash : (Map) hash.get(path)) + .toList(); + } + + public static Map throwList(DetailNode node) { + var result = new LinkedHashMap(); + javaDocTag( + node, + THROWS, + (tag, text) -> { + var statusCode = + tree(tag) + .filter(javadocToken(JavadocCommentsTokenTypes.DESCRIPTION)) + .findFirst() + .flatMap( + it -> + tree(it) + .filter(tagName -> tagName.getText().equals("code")) + .map(DetailNode::getParent) + .map(DetailNode::getParent) + .flatMap( + start -> + children(start) + .filter( + javadocToken(JavadocCommentsTokenTypes.HTML_CONTENT))) + .flatMap( + htmlTag -> + children(htmlTag) + .filter(javadocToken(JavadocCommentsTokenTypes.TEXT)) + .findFirst() + .stream()) + .map(DetailNode::getText) + .map( + value -> { + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return null; + } + }) + .filter(Objects::nonNull) + .filter(code -> code >= 400 && code <= 600) + .map(StatusCode::valueOf) + .findFirst()) + .orElse(null); + if (statusCode != null) { + if (text == null || text.trim().isEmpty()) { + text = statusCode.reason(); + } else { + text = statusCode.reason() + ": " + text; + } + var throwsDoc = new ApiResponse(); + throwsDoc.setDescription(text); + result.putIfAbsent(statusCode, throwsDoc); + } + }); + return result; + } + + public static Map extensions(DetailNode node) { + return parse(node, EXTENSION, null).stream().findFirst().orElse(Map.of()); + } + + public static List tags(DetailNode node) { + var tags = + parse(node, TAG_SHORT, null).stream() + .map(hash -> hash.get("tag").toString().trim()) + .map( + value -> { + // look first space + var indexOf = value.indexOf('.'); + String name; + String description; + if (indexOf > 0) { + name = value.substring(0, indexOf).trim(); + description = value.substring(indexOf + 1).trim(); + } else { + name = value; + description = null; + } + return createTag(name, description); + }) + .collect(Collectors.toList()); + parse(node, TAG, "tag").stream() + .map(hash -> createTag((String) hash.get("name"), (String) hash.get("description"))) + .forEach(tags::add); + return tags; + } + + private static Tag createTag(String tagName, String tagDescription) { + Tag tag = new Tag(); + tag.setName(tagName); + tag.setDescription(tagDescription); + return tag; + } + + public static void javaDocTag( + DetailNode tree, Predicate filter, Consumer2 consumer) { + javaDocTag(tree, filter, (tag, value, text) -> consumer.accept(tag, text)); + } + + public static void javaDocTag( + DetailNode tree, + Predicate filter, + Consumer3 consumer) { + if (tree != JavaDocNode.EMPTY_NODE) { + for (var tag : + tree(tree).filter(javadocToken(JavadocCommentsTokenTypes.JAVADOC_BLOCK_TAG)).toList()) { + var tagName = tree(tag).filter(filter).findFirst().orElse(null); + if (tagName != null) { + var tagValue = + tree(tag) + .filter(javadocToken(JavadocCommentsTokenTypes.DESCRIPTION)) + .findFirst() + .orElse(null); + var tagText = tagValue == null ? null : getText(children(tagValue).toList(), true); + consumer.accept(tagName, tagValue, tagText); + } + } + } + } + + public static String operationId(DetailNode javadoc) { + var operationId = new ArrayList(); + javaDocTag( + javadoc, + OPERATION_ID, + (tag, value, text) -> { + operationId.add(text); + }); + return operationId.isEmpty() ? null : operationId.getFirst(); + } +} diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/MethodDoc.java b/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/MethodDoc.java new file mode 100644 index 0000000000..0a172ad8cb --- /dev/null +++ b/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/MethodDoc.java @@ -0,0 +1,125 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.javadoc; + +import static io.jooby.internal.javadoc.JavaDocStream.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.puppycrawl.tools.checkstyle.api.DetailAST; +import com.puppycrawl.tools.checkstyle.api.DetailNode; +import com.puppycrawl.tools.checkstyle.api.JavadocCommentsTokenTypes; +import com.puppycrawl.tools.checkstyle.api.TokenTypes; +import io.jooby.StatusCode; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.security.SecurityRequirement; + +public class MethodDoc extends JavaDocNode { + private Map parameters; + private List securityRequeriments; + private String operationId; + private Map throwList; + private List parameterTypes = null; + private String returnDoc; + + public MethodDoc(JavaDocParser ctx, DetailAST node, DetailAST javadoc) { + super(ctx, node, javadoc); + throwList = JavaDocTag.throwList(this.javadoc); + operationId = JavaDocTag.operationId(this.javadoc); + securityRequeriments = JavaDocTag.securityRequirement(this.javadoc); + parameters = JavaDocTag.getParametersDoc(this.javadoc); + returnDoc = JavaDocTag.getReturnDoc(this.javadoc); + } + + MethodDoc(JavaDocParser ctx, DetailAST node, DetailNode javadoc) { + super(ctx, node, javadoc); + } + + public String getName() { + return node.findFirstToken(TokenTypes.IDENT).getText(); + } + + public String getOperationId() { + return operationId; + } + + public void setOperationId(String operationId) { + this.operationId = operationId; + } + + public List getSecurityRequirements() { + return securityRequeriments; + } + + public List getParameterTypes() { + if (parameterTypes == null) { + parameterTypes = new ArrayList<>(); + var classDef = + backward(node) + .filter(JavaDocSupport.TYPES) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Class not found: " + node)); + for (var parameter : tree(node).filter(tokens(TokenTypes.PARAMETER_DEF)).toList()) { + var type = + children(parameter) + .filter(tokens(TokenTypes.TYPE)) + .findFirst() + .orElseThrow( + () -> + new IllegalArgumentException( + "Parameter type not found: " + + JavaDocSupport.getSimpleName(parameter))); + parameterTypes.add( + JavaDocSupport.toQualifiedName(classDef, JavaDocSupport.getQualifiedName(type))); + } + } + return parameterTypes; + } + + public MethodDoc markAsVirtual() { + parameterTypes = List.of(); + return this; + } + + public List getJavadocParameterNames() { + return tree(javadoc) + // must be a tag + .filter(javadocToken(JavadocCommentsTokenTypes.JAVADOC_BLOCK_TAG)) + .flatMap( + it -> + tree(it) + .filter(javadocToken(JavadocCommentsTokenTypes.PARAMETER_NAME)) + .map(DetailNode::getText)) + .toList(); + } + + public String getParameterDoc(String name) { + var doc = parameters.get(name); + return doc == null ? null : doc.replace(exampleCode(doc), "").trim(); + } + + public Object getParameterExample(String name) { + return toExamples(exampleCode(parameters.get(name))); + } + + public String getReturnDoc() { + if (returnDoc != null) { + var result = returnDoc.replace(exampleCode(returnDoc), "").trim(); + return result.isEmpty() ? null : result; + } + return null; + } + + public Object getReturnExample() { + return toExamples(returnDoc); + } + + public Map getThrows() { + return throwList; + } +} diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/PathDoc.java b/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/PathDoc.java new file mode 100644 index 0000000000..f9ff8fcc01 --- /dev/null +++ b/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/PathDoc.java @@ -0,0 +1,14 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.javadoc; + +import com.puppycrawl.tools.checkstyle.api.DetailAST; + +public class PathDoc extends JavaDocNode { + public PathDoc(JavaDocParser ctx, DetailAST node, DetailAST comment) { + super(ctx, node, comment); + } +} diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/ScriptDoc.java b/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/ScriptDoc.java new file mode 100644 index 0000000000..bbd848b0d0 --- /dev/null +++ b/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/ScriptDoc.java @@ -0,0 +1,42 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.javadoc; + +import com.puppycrawl.tools.checkstyle.api.DetailAST; + +public class ScriptDoc extends MethodDoc { + private final String method; + private final String pattern; + private PathDoc path; + + public ScriptDoc( + JavaDocParser ctx, String method, String pattern, DetailAST node, DetailAST javadoc) { + super(ctx, node, javadoc); + this.method = method; + this.pattern = pattern; + } + + public PathDoc getPath() { + return path; + } + + public void setPath(PathDoc path) { + this.path = path; + } + + public String getMethod() { + return method; + } + + public String getPattern() { + return pattern; + } + + @Override + public String toString() { + return method + " " + pattern; + } +} diff --git a/modules/pom.xml b/modules/pom.xml index 5f14a492cf..fdfdb789dd 100644 --- a/modules/pom.xml +++ b/modules/pom.xml @@ -28,6 +28,7 @@ jooby-undertow jooby-vertx + jooby-javadoc jooby-openapi jooby-swagger-ui jooby-redoc From 23dbed6f8890ef07f05ebc98445b8d7c680fd8fc Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Wed, 18 Mar 2026 20:08:59 -0300 Subject: [PATCH 06/56] build: extract javadoc from open-api --- modules/jooby-javadoc/README.md | 23 ++ modules/jooby-javadoc/pom.xml | 142 ++----- .../jooby/internal/javadoc/JavaDocParser.java | 338 ---------------- .../io/jooby/internal/javadoc/JavaDocTag.java | 372 ------------------ .../{internal => }/javadoc/ClassDoc.java | 30 +- .../javadoc/ContentSplitter.java | 4 +- .../main/java/io/jooby/javadoc/DocTag.java | 8 + .../{internal => }/javadoc/FieldDoc.java | 2 +- .../{internal => }/javadoc/JavaDocNode.java | 16 +- .../javadoc/JavaDocObjectParser.java | 4 +- .../java/io/jooby}/javadoc/JavaDocParser.java | 78 +++- .../{internal => }/javadoc/JavaDocStream.java | 4 +- .../javadoc/JavaDocSupport.java | 8 +- .../java/io/jooby/javadoc/JavaDocTag.java | 321 +++++++++++++++ .../{internal => }/javadoc/MethodDoc.java | 15 +- .../jooby/{internal => }/javadoc/PathDoc.java | 2 +- .../{internal => }/javadoc/ScriptDoc.java | 2 +- .../src/main/java/io/jooby/javadoc/Utils.java | 171 ++++++++ .../src/main/java/module-info.java | 5 + .../jooby}/javadoc/ContentSplitterTest.java | 4 +- .../javadoc/JavaDocObjectParserTest.java | 7 +- .../io/jooby}/javadoc/JavaDocParserTest.java | 54 ++- .../io/jooby/javadoc/JavadocTestHelper.java | 58 +++ .../java/io/jooby/javadoc/MethodDocTest.java | 184 +++++++++ .../java/io/jooby}/javadoc/PrintAstTree.java | 4 +- .../java/io/jooby/javadoc/input/Address.java | 57 +++ .../java/io/jooby}/javadoc/input/ApiDoc.java | 8 +- .../java/io/jooby/javadoc/input/Author.java | 55 +++ .../java/io/jooby/javadoc/input/Book.java | 82 ++++ .../java/io/jooby}/javadoc/input/EnumDoc.java | 2 +- .../io/jooby}/javadoc/input/InterfaceDoc.java | 2 +- .../io/jooby}/javadoc/input/LambdaRefApp.java | 7 +- .../javadoc/input/MultilineComment.java | 2 +- .../io/jooby}/javadoc/input/NoClassDoc.java | 2 +- .../java/io/jooby}/javadoc/input/NoDoc.java | 2 +- .../io/jooby}/javadoc/input/QueryBeanDoc.java | 2 +- .../jooby}/javadoc/input/RecordBeanDoc.java | 2 +- .../jooby}/javadoc/input/RequestHandler.java | 2 +- .../io/jooby}/javadoc/input/ScopeDoc.java | 2 +- .../io/jooby}/javadoc/input/ScriptApp.java | 2 +- .../io/jooby}/javadoc/input/Subclass.java | 4 +- .../java/io/jooby/javadoc/input/Type.java | 21 + .../io/jooby}/javadoc/input/sub/Base.java | 2 +- .../javadoc/input/sub/SubPackageHandler.java | 2 +- modules/jooby-openapi/pom.xml | 25 +- .../jooby/internal/openapi/JavaDocMapper.java | 212 ++++++++++ .../jooby/internal/openapi/JavaDocSetter.java | 21 +- .../jooby/internal/openapi/ParserContext.java | 2 +- .../internal/openapi/javadoc/ClassDoc.java | 274 ------------- .../openapi/javadoc/ContentSplitter.java | 158 -------- .../internal/openapi/javadoc/FieldDoc.java | 29 -- .../internal/openapi/javadoc/JavaDocNode.java | 257 ------------ .../openapi/javadoc/JavaDocObjectParser.java | 361 ----------------- .../openapi/javadoc/JavaDocStream.java | 248 ------------ .../openapi/javadoc/JavaDocSupport.java | 123 ------ .../internal/openapi/javadoc/JavaDocTag.java | 372 ------------------ .../internal/openapi/javadoc/MethodDoc.java | 123 ------ .../internal/openapi/javadoc/PathDoc.java | 14 - .../internal/openapi/javadoc/ScriptDoc.java | 42 -- .../io/jooby/openapi/OpenAPIGenerator.java | 14 +- .../src/main/java/module-info.java | 1 + pom.xml | 8 + 62 files changed, 1418 insertions(+), 2980 deletions(-) create mode 100644 modules/jooby-javadoc/README.md delete mode 100644 modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocParser.java delete mode 100644 modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocTag.java rename modules/jooby-javadoc/src/main/java/io/jooby/{internal => }/javadoc/ClassDoc.java (90%) rename modules/jooby-javadoc/src/main/java/io/jooby/{internal => }/javadoc/ContentSplitter.java (98%) create mode 100644 modules/jooby-javadoc/src/main/java/io/jooby/javadoc/DocTag.java rename modules/jooby-javadoc/src/main/java/io/jooby/{internal => }/javadoc/FieldDoc.java (95%) rename modules/jooby-javadoc/src/main/java/io/jooby/{internal => }/javadoc/JavaDocNode.java (94%) rename modules/jooby-javadoc/src/main/java/io/jooby/{internal => }/javadoc/JavaDocObjectParser.java (99%) rename modules/{jooby-openapi/src/main/java/io/jooby/internal/openapi => jooby-javadoc/src/main/java/io/jooby}/javadoc/JavaDocParser.java (85%) rename modules/jooby-javadoc/src/main/java/io/jooby/{internal => }/javadoc/JavaDocStream.java (98%) rename modules/jooby-javadoc/src/main/java/io/jooby/{internal => }/javadoc/JavaDocSupport.java (96%) create mode 100644 modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocTag.java rename modules/jooby-javadoc/src/main/java/io/jooby/{internal => }/javadoc/MethodDoc.java (88%) rename modules/jooby-javadoc/src/main/java/io/jooby/{internal => }/javadoc/PathDoc.java (90%) rename modules/jooby-javadoc/src/main/java/io/jooby/{internal => }/javadoc/ScriptDoc.java (95%) create mode 100644 modules/jooby-javadoc/src/main/java/io/jooby/javadoc/Utils.java create mode 100644 modules/jooby-javadoc/src/main/java/module-info.java rename modules/{jooby-openapi/src/test/java/io/jooby/internal/openapi => jooby-javadoc/src/test/java/io/jooby}/javadoc/ContentSplitterTest.java (98%) rename modules/{jooby-openapi/src/test/java => jooby-javadoc/src/test/java/io/jooby}/javadoc/JavaDocObjectParserTest.java (97%) rename modules/{jooby-openapi/src/test/java => jooby-javadoc/src/test/java/io/jooby}/javadoc/JavaDocParserTest.java (92%) create mode 100644 modules/jooby-javadoc/src/test/java/io/jooby/javadoc/JavadocTestHelper.java create mode 100644 modules/jooby-javadoc/src/test/java/io/jooby/javadoc/MethodDocTest.java rename modules/{jooby-openapi/src/test/java => jooby-javadoc/src/test/java/io/jooby}/javadoc/PrintAstTree.java (94%) create mode 100644 modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/Address.java rename modules/{jooby-openapi/src/test/java => jooby-javadoc/src/test/java/io/jooby}/javadoc/input/ApiDoc.java (91%) create mode 100644 modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/Author.java create mode 100644 modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/Book.java rename modules/{jooby-openapi/src/test/java => jooby-javadoc/src/test/java/io/jooby}/javadoc/input/EnumDoc.java (87%) rename modules/{jooby-openapi/src/test/java => jooby-javadoc/src/test/java/io/jooby}/javadoc/input/InterfaceDoc.java (88%) rename modules/{jooby-openapi/src/test/java => jooby-javadoc/src/test/java/io/jooby}/javadoc/input/LambdaRefApp.java (85%) rename modules/{jooby-openapi/src/test/java => jooby-javadoc/src/test/java/io/jooby}/javadoc/input/MultilineComment.java (94%) rename modules/{jooby-openapi/src/test/java => jooby-javadoc/src/test/java/io/jooby}/javadoc/input/NoClassDoc.java (93%) rename modules/{jooby-openapi/src/test/java => jooby-javadoc/src/test/java/io/jooby}/javadoc/input/NoDoc.java (92%) rename modules/{jooby-openapi/src/test/java => jooby-javadoc/src/test/java/io/jooby}/javadoc/input/QueryBeanDoc.java (97%) rename modules/{jooby-openapi/src/test/java => jooby-javadoc/src/test/java/io/jooby}/javadoc/input/RecordBeanDoc.java (92%) rename modules/{jooby-openapi/src/test/java => jooby-javadoc/src/test/java/io/jooby}/javadoc/input/RequestHandler.java (90%) rename modules/{jooby-openapi/src/test/java => jooby-javadoc/src/test/java/io/jooby}/javadoc/input/ScopeDoc.java (95%) rename modules/{jooby-openapi/src/test/java => jooby-javadoc/src/test/java/io/jooby}/javadoc/input/ScriptApp.java (98%) rename modules/{jooby-openapi/src/test/java => jooby-javadoc/src/test/java/io/jooby}/javadoc/input/Subclass.java (87%) create mode 100644 modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/Type.java rename modules/{jooby-openapi/src/test/java => jooby-javadoc/src/test/java/io/jooby}/javadoc/input/sub/Base.java (92%) rename modules/{jooby-openapi/src/test/java => jooby-javadoc/src/test/java/io/jooby}/javadoc/input/sub/SubPackageHandler.java (90%) create mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/JavaDocMapper.java delete mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java delete mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ContentSplitter.java delete mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/FieldDoc.java delete mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java delete mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocObjectParser.java delete mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocStream.java delete mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocSupport.java delete mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocTag.java delete mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MethodDoc.java delete mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/PathDoc.java delete mode 100644 modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ScriptDoc.java diff --git a/modules/jooby-javadoc/README.md b/modules/jooby-javadoc/README.md new file mode 100644 index 0000000000..9d7e159c1c --- /dev/null +++ b/modules/jooby-javadoc/README.md @@ -0,0 +1,23 @@ +# Jooby Javadoc (Internal Module) + +> ⚠️ **STOP: INTERNAL USAGE ONLY** ⚠️ +> +> **This module is strictly for internal use by the Jooby framework. It is NOT intended for public consumption or end-user applications.** + +## 🛑 Do Not Build Against This API + +Please be aware that the classes and interfaces within this module are considered **private internal implementation details**. We strongly discourage developers from writing code that directly depends on this module. + +If you choose to use this module in your own projects, you do so entirely at your own risk. Please note: + +* **No Backward Compatibility:** We do not follow semantic versioning for these internal packages. +* **Sudden Breaking Changes:** APIs, method signatures, and entire classes can (and will) be modified, renamed, or completely removed at any time without prior notice or deprecation cycles. +* **No Public Support:** Issues or feature requests related to direct third-party usage of this module will be closed. + +## What is this for? +This module contains the abstract syntax tree (AST) extraction logic used internally to parse Javadoc comments. It exists solely to power the official `jooby-openapi` generator. + +If your goal is to generate OpenAPI documentation for your application, please depend on the official, public-facing `jooby-openapi` module instead. + +--- +*If you are a core contributor actively working on Jooby's OpenAPI generation pipeline, you are in the right place.* diff --git a/modules/jooby-javadoc/pom.xml b/modules/jooby-javadoc/pom.xml index fe09d89190..16a66111b3 100644 --- a/modules/jooby-javadoc/pom.xml +++ b/modules/jooby-javadoc/pom.xml @@ -1,5 +1,7 @@ - + 4.0.0 @@ -11,29 +13,11 @@ jooby-javadoc jooby-javadoc - - - io.jooby - jooby - ${jooby.version} - - - - - jakarta.ws.rs - jakarta.ws.rs-api - - - - jakarta.validation - jakarta.validation-api - - - - io.swagger.core.v3 - swagger-models - + + + + com.puppycrawl.tools checkstyle @@ -48,16 +32,15 @@ - org.apache.commons - commons-lang3 - 3.20.0 - test + io.jooby + jooby + ${jooby.version} + provided - io.avaje - avaje-inject - 12.4 + jakarta.validation + jakarta.validation-api test @@ -75,37 +58,6 @@ test - - - io.jooby - jooby-kotlin - ${jooby.version} - test - - - - org.jetbrains.kotlin - kotlin-stdlib - test - - - - org.jetbrains.kotlin - kotlin-reflect - test - - - - org.jetbrains.kotlinx - kotlinx-coroutines-core - test - - - net.bytebuddy - byte-buddy - 1.18.7 - test - org.mockito mockito-core @@ -119,56 +71,20 @@ - - - - org.jetbrains.kotlin - kotlin-maven-plugin - - - compile - none - - - test-compile - - - ${project.basedir}/src/test/kotlin - ${project.basedir}/src/test/java - - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - ${maven-compiler-plugin.version} - - none - - - - default-testCompile - none - - - - - - - - - - java-test-compile - - testCompile - - test-compile - - - - - + + + + + + + + + + + + + + + + diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocParser.java b/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocParser.java deleted file mode 100644 index 35b6823011..0000000000 --- a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocParser.java +++ /dev/null @@ -1,338 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.javadoc; - -import static com.puppycrawl.tools.checkstyle.JavaParser.parseFile; -import static io.jooby.SneakyThrows.throwingFunction; -import static io.jooby.internal.javadoc.JavaDocStream.*; -import static io.jooby.internal.javadoc.JavaDocStream.tokens; -import static io.jooby.internal.javadoc.JavaDocSupport.*; -import static java.util.Optional.ofNullable; - -import java.io.File; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.*; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.BiConsumer; -import java.util.function.Predicate; - -import com.puppycrawl.tools.checkstyle.JavaParser; -import com.puppycrawl.tools.checkstyle.api.DetailAST; -import com.puppycrawl.tools.checkstyle.api.TokenTypes; -import com.puppycrawl.tools.checkstyle.utils.TokenUtil; -import com.puppycrawl.tools.checkstyle.utils.XpathUtil; -import io.jooby.Context; -import io.jooby.Router; - -public class JavaDocParser { - - public static final JavaDocParser NOOP = new JavaDocParser(List.of()); - - private record ScriptRef(String operationId, DetailAST comment) {} - - private final List baseDir; - private final Map cache = new HashMap<>(); - - public JavaDocParser(Path baseDir) { - this(List.of(baseDir)); - } - - public JavaDocParser(List baseDir) { - this.baseDir = baseDir; - } - - public Optional parse(String typeName) { - return ofNullable(traverse(resolveType(typeName)).get(typeName)); - } - - public DetailAST resolve(Path path) { - return lookup(path) - .map( - it -> - cache.computeIfAbsent( - it, - throwingFunction( - filePath -> { - return parseFile(filePath.toFile(), JavaParser.Options.WITH_COMMENTS); - }))) - .orElse(JavaDocNode.EMPTY_AST); - } - - private Map traverse(DetailAST tree) { - var classes = new HashMap(); - traverse( - List.of(tree), - TYPES, - modifiers -> tree(modifiers).noneMatch(tokens(TokenTypes.LITERAL_PRIVATE)), - (scope, comment) -> { - var scopes = typeHierarchy(scope); - var counter = new AtomicInteger(0); - counter.addAndGet(comment == JavaDocNode.EMPTY_AST ? 0 : 1); - var classDoc = new ClassDoc(this, scope, comment); - - // MVC routes - traverse( - scopes, - tokens(TokenTypes.VARIABLE_DEF, TokenTypes.METHOD_DEF), - modifiers -> tree(modifiers).noneMatch(tokens(TokenTypes.LITERAL_STATIC)), - (member, memberComment) -> { - counter.addAndGet(memberComment == JavaDocNode.EMPTY_AST ? 0 : 1); - // check member belong to current scope - if (backward(member) - .filter(TYPES) - .findFirst() - .filter(scopes::contains) - .isPresent()) { - if (member.getType() == TokenTypes.VARIABLE_DEF) { - classDoc.addField(new FieldDoc(this, member, memberComment)); - } else { - classDoc.addMethod(new MethodDoc(this, member, memberComment)); - } - } - }); - // Script routes - counter.addAndGet(scripts(scope, classDoc, null, null, new HashSet<>())); - - if (counter.get() > 0) { - classes.put(classDoc.getName(), classDoc); - } - }); - return classes; - } - - private void traverse( - List scopes, - Predicate types, - Predicate modifiers, - BiConsumer action) { - - for (var scope : scopes) { - for (var node : tree(scope).filter(types).toList()) { - var mods = - tree(node) - .filter(tokens(TokenTypes.MODIFIERS)) - .findFirst() - .orElseThrow( - () -> - new IllegalArgumentException( - "Modifiers not found on " + TokenUtil.getTokenName(node.getType()))); - if (modifiers.test(mods)) { - var docRoot = node.getType() == TokenTypes.VARIABLE_DEF ? mods.getParent() : mods; - var comment = - tree(docRoot) - .filter(tokens(TokenTypes.BLOCK_COMMENT_BEGIN)) - .findFirst() - .orElse(JavaDocNode.EMPTY_AST); - action.accept(node, comment); - } - } - } - } - - private List typeHierarchy(DetailAST scope) { - var result = new ArrayList(); - // call parent members first, so we can inherit and override later. - children(scope) - .filter(tokens(TokenTypes.EXTENDS_CLAUSE)) - .findFirst() - .map(it -> JavaDocSupport.toQualifiedName(scope, getQualifiedName(it))) - .flatMap( - superClass -> - tree(resolveType(superClass)) - .filter(TYPES) - .filter(it -> superClass.endsWith("." + getSimpleName(it))) - .findFirst()) - .ifPresent(parent -> result.addAll(typeHierarchy(parent))); - result.add(scope); - return result; - } - - private int scripts( - DetailAST scope, ClassDoc classDoc, PathDoc pathDoc, String prefix, Set visited) { - var counter = new AtomicInteger(0); - for (var script : tree(scope).filter(tokens(TokenTypes.METHOD_CALL)).toList()) { - if (visited.add(script)) { - // Test for HTTP method name - var callName = - tree(script) - .filter(tokens(TokenTypes.IDENT)) - .findFirst() - .map(DetailAST::getText) - .stream() - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("No method call found: " + script)); - var scriptComment = - children(script) - .filter(tokens(TokenTypes.BLOCK_COMMENT_BEGIN)) - .findFirst() - .orElse(JavaDocNode.EMPTY_AST); - if (scriptComment != JavaDocNode.EMPTY_AST) { - counter.incrementAndGet(); - } - if (Router.METHODS.contains(callName.toUpperCase())) { - pathLiteral(script) - .ifPresent( - pattern -> { - var resolvedComment = resolveScriptComment(classDoc, script, scriptComment); - if (scriptComment != resolvedComment.comment) { - counter.incrementAndGet(); - } - var scriptDoc = - new ScriptDoc( - this, - callName.toUpperCase(), - computePath(prefix, pattern), - script, - resolvedComment.comment); - if (resolvedComment.operationId() != null) { - scriptDoc.setOperationId(resolvedComment.operationId()); - } - scriptDoc.setPath(pathDoc); - classDoc.addScript(scriptDoc); - }); - } else if ("path".equals(callName)) { - pathLiteral(script) - .ifPresent( - path -> { - scripts( - script, - classDoc, - new PathDoc(this, script, scriptComment), - computePath(prefix, path), - visited); - }); - } - } - } - return counter.get(); - } - - /** - * get("/reference", this::findPetById); post("/static-reference", - * javadoc.input.LambdaRefApp::staticFindPetById); put("/external-reference", - * RequestHandler::external); get("/external-subPackage-reference", - * SubPackageHandler::subPackage); - * - * @param classDoc - * @param script - * @param defaultComment - * @return - */ - private ScriptRef resolveScriptComment( - ClassDoc classDoc, DetailAST script, DetailAST defaultComment) { - // ELIST -> LAMBDA (children) - // ELIST -> EXPR -> METHOD_REF (tree) - return children(script) - .filter(tokens(TokenTypes.ELIST)) - .findFirst() - .map( - statementList -> - children(statementList) - .filter(tokens(TokenTypes.LAMBDA)) - .findFirst() - .map(lambda -> new ScriptRef(null, defaultComment)) - .orElseGet( - () -> - tree(statementList) - .filter(tokens(TokenTypes.METHOD_REF)) - .findFirst() - .flatMap(ref -> ofNullable(resolveFromMethodRef(classDoc, ref))) - .orElseGet(() -> new ScriptRef(null, defaultComment)))) - .orElseGet(() -> new ScriptRef(null, defaultComment)); - } - - private ScriptRef resolveFromMethodRef(ClassDoc classDoc, DetailAST methodRef) { - var referenceOwner = getQualifiedName(methodRef); - DetailAST scope = null; - String className; - if (referenceOwner.equals("this")) { - scope = classDoc.getNode(); - className = classDoc.getName(); - } else { - // resolve className - className = toQualifiedName(classDoc.node, referenceOwner); - scope = resolveType(className); - if (scope == JavaDocNode.EMPTY_AST) { - // not found - return null; - } - } - var methodName = - children(methodRef).filter(tokens(TokenTypes.IDENT)).toList().getLast().getText(); - var method = - tree(scope) - .filter(tokens(TokenTypes.METHOD_DEF)) - .filter( - it -> - children(it) - .filter(tokens(TokenTypes.IDENT)) - .findFirst() - .filter(e -> e.getText().equals(methodName)) - .isPresent()) - // One Argument - .filter(it -> tree(it).filter(tokens(TokenTypes.PARAMETER_DEF)).count() == 1) - // Context Type - .filter( - it -> - tree(it) - .filter(tokens(TokenTypes.PARAMETER_DEF)) - .findFirst() - .flatMap(p -> children(p).filter(tokens(TokenTypes.TYPE)).findFirst()) - .filter( - type -> - toQualifiedName(classDoc.node, getQualifiedName(type)) - .equals(Context.class.getName())) - .isPresent()) - .findFirst() - .orElseThrow( - () -> - new IllegalArgumentException( - "No method found: " + className + "." + methodName)); - return children(method) - .filter(tokens(TokenTypes.MODIFIERS)) - .findFirst() - .flatMap(it -> children(it).filter(tokens(TokenTypes.BLOCK_COMMENT_BEGIN)).findFirst()) - .map(comment -> new ScriptRef(methodName, comment)) - .orElseGet(() -> new ScriptRef(null, JavaDocNode.EMPTY_AST)); - } - - /** - * ELIST -> EXPR -> STRING_LITERAL - * - * @param script Get string literal from method call. - * @return String literal. - */ - private static Optional pathLiteral(DetailAST script) { - return children(script) - .filter(tokens(TokenTypes.ELIST)) - .findFirst() - .flatMap(it -> children(it).filter(tokens(TokenTypes.EXPR)).findFirst()) - .flatMap(it -> children(it).filter(tokens(TokenTypes.STRING_LITERAL)).findFirst()) - .map(XpathUtil::getTextAttributeValue); - } - - private String computePath(String prefix, String pattern) { - if (prefix == null) { - return Router.normalizePath(pattern); - } - return Router.noTrailingSlash(Router.normalizePath(prefix + pattern)); - } - - private DetailAST resolveType(String typeName) { - var segments = typeName.split("\\."); - segments[segments.length - 1] = segments[segments.length - 1] + ".java"; - return resolve(Paths.get(String.join(File.separator, segments))); - } - - private Optional lookup(Path path) { - return baseDir.stream() - .map(parentDir -> parentDir.resolve(path)) - .filter(Files::exists) - .findFirst(); - } -} diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocTag.java b/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocTag.java deleted file mode 100644 index fd06c65309..0000000000 --- a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocTag.java +++ /dev/null @@ -1,372 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.javadoc; - -import static io.jooby.internal.javadoc.JavaDocNode.getText; -import static io.jooby.internal.javadoc.JavaDocStream.*; -import static io.jooby.internal.javadoc.JavaDocStream.children; -import static java.util.Optional.ofNullable; - -import java.util.*; -import java.util.function.Consumer; -import java.util.function.Predicate; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import com.puppycrawl.tools.checkstyle.api.DetailNode; -import com.puppycrawl.tools.checkstyle.api.JavadocCommentsTokenTypes; -import io.jooby.SneakyThrows.Consumer2; -import io.jooby.SneakyThrows.Consumer3; -import io.jooby.StatusCode; -import io.swagger.v3.oas.models.info.Contact; -import io.swagger.v3.oas.models.info.License; -import io.swagger.v3.oas.models.responses.ApiResponse; -import io.swagger.v3.oas.models.security.*; -import io.swagger.v3.oas.models.servers.Server; -import io.swagger.v3.oas.models.tags.Tag; - -public class JavaDocTag { - private static final Predicate TAG_SHORT = it -> it.getText().equals("tag"); - private static final Predicate TAG = it -> it.getText().startsWith("tag."); - private static final Predicate SERVER = it -> it.getText().startsWith("server."); - private static final Predicate SECURITY = - it -> it.getText().equals("security") || it.getText().equals("securityRequirement"); - private static final Predicate SECURITY_SCHEME = - it -> it.getText().startsWith("securityScheme."); - private static final Predicate CONTACT = it -> it.getText().startsWith("contact."); - private static final Predicate LICENSE = it -> it.getText().startsWith("license."); - private static final Predicate OPERATION_ID = - it -> it.getText().equals("operationId"); - private static final Predicate EXTENSION = it -> it.getText().startsWith("x-"); - private static final Predicate THROWS = - it -> tree(it).anyMatch(javadocToken(JavadocCommentsTokenTypes.THROWS_BLOCK_TAG)); - private static final Predicate PARAM = - it -> tree(it).anyMatch(javadocToken(JavadocCommentsTokenTypes.PARAM_BLOCK_TAG)); - - public static Map getParametersDoc(DetailNode node) { - var parameters = new LinkedHashMap(); - javaDocTag( - node, - PARAM, - (tag, value) -> { - tree(tag) - .filter(javadocToken(JavadocCommentsTokenTypes.PARAMETER_NAME)) - .findFirst() - .map(DetailNode::getText) - .ifPresent( - name -> - tree(tag) - .filter(javadocToken(JavadocCommentsTokenTypes.DESCRIPTION)) - .findFirst() - .map(description -> JavaDocNode.getText(tree(description).toList(), true)) - .ifPresent(text -> parameters.put(name, text))); - }); - return parameters; - } - - public static String getReturnDoc(DetailNode node) { - var text = new StringBuilder(); - javaDocTag( - node, - javadocToken(JavadocCommentsTokenTypes.RETURN_BLOCK_TAG), - (tag, value) -> { - tree(tag.getParent()) - .filter(javadocToken(JavadocCommentsTokenTypes.DESCRIPTION)) - .findFirst() - .map(description -> JavaDocNode.getText(tree(description).toList(), true)) - .ifPresent(text::append); - }); - var result = text.toString().trim(); - return result.isEmpty() ? null : result; - } - - public static List securityRequirement(DetailNode node) { - return parse(node, SECURITY, null).stream() - .map( - hash -> - hash.containsKey("security") - ? hash.get("security") - : hash.get("securityRequirement")) - .map(Object::toString) - .map(String::trim) - .map( - value -> { - // look first space - var indexOf = value.indexOf(' '); - String key; - String scopes; - if (indexOf > 0) { - key = value.substring(0, indexOf).trim(); - scopes = value.substring(indexOf + 1).trim(); - } else { - key = value; - scopes = ""; - } - if (scopes.startsWith("[") && scopes.endsWith("]")) { - scopes = scopes.substring(1, scopes.length() - 1); - } - var scopeList = Stream.of(scopes.split(",")).map(String::trim).toList(); - var security = new SecurityRequirement(); - security.addList(key, scopeList); - return security; - }) - .toList(); - } - - public static Map securitySchemes(DetailNode node) { - var result = new LinkedHashMap(); - parse(node, SECURITY_SCHEME, "securityScheme") - .forEach( - hash -> { - var item = new SecurityScheme(); - item.setDescription((String) hash.get("description")); - var name = (String) hash.get("name"); - item.setName((String) hash.get("paramName")); - ofNullable((String) hash.get("in")) - .map(String::toUpperCase) - .map(SecurityScheme.In::valueOf) - .ifPresent(item::setIn); - ofNullable((String) hash.get("type")) - .map(String::toUpperCase) - .map(SecurityScheme.Type::valueOf) - .ifPresent(item::setType); - item.setBearerFormat((String) hash.get("bearerFormat")); - item.setOpenIdConnectUrl((String) hash.get("openIdConnectUrl")); - item.setScheme((String) hash.get("scheme")); - var objectFlows = hash.get("flows"); - if (objectFlows instanceof Map hashFlows) { - OAuthFlows flows = new OAuthFlows(); - toOauthFlow("implicit", hashFlows, flows::setImplicit); - toOauthFlow("password", hashFlows, flows::setPassword); - toOauthFlow("authorizationCode", hashFlows, flows::setAuthorizationCode); - toOauthFlow("clientCredentials", hashFlows, flows::setClientCredentials); - item.setFlows(flows); - } - result.put(name, item); - }); - return result; - } - - private static void toOauthFlow(String path, Map flows, Consumer consumer) { - var flowHash = flows.get(path); - if (flowHash instanceof Map hash) { - var oauthFlow = new OAuthFlow(); - oauthFlow.setAuthorizationUrl((String) hash.get("authorizationUrl")); - oauthFlow.setTokenUrl((String) hash.get("tokenUrl")); - oauthFlow.setRefreshUrl((String) hash.get("refreshUrl")); - var scopesObject = hash.get("scopes"); - List scopeNames; - List scopeDescriptions; - if (scopesObject instanceof Map scopesHash) { - scopeNames = ensureList(scopesHash.get("name")); - scopeDescriptions = ensureList(scopesHash.get("description")); - } else { - scopeNames = ensureList(scopesObject); - scopeDescriptions = List.of(); - } - if (!scopeNames.isEmpty()) { - Scopes scopes = new Scopes(); - for (int i = 0; i < scopeNames.size(); i++) { - var description = i < scopeDescriptions.size() ? scopeDescriptions.get(i) : ""; - scopes.addString(scopeNames.get(i), description); - } - oauthFlow.setScopes(scopes); - } - consumer.accept(oauthFlow); - } - } - - @SuppressWarnings({"unchecked", "rawtypes"}) - private static List ensureList(Object value) { - if (value == null) return List.of(); - if (value instanceof List list) { - return list.stream().map(Objects::toString).toList(); - } - return List.of(value.toString()); - } - - public static List servers(DetailNode node) { - return parse(node, SERVER, "server").stream() - .map( - hash -> { - var server = new Server(); - server.setDescription((String) hash.get("description")); - server.setUrl((String) hash.get("url")); - return server; - }) - .toList(); - } - - public static List contacts(DetailNode node) { - return parse(node, CONTACT, "contact").stream() - .map( - hash -> { - var item = new Contact(); - item.setName((String) hash.get("name")); - item.setUrl((String) hash.get("url")); - item.setEmail((String) hash.get("email")); - return item; - }) - .toList(); - } - - public static List license(DetailNode node) { - return parse(node, LICENSE, "license").stream() - .map( - hash -> { - var item = new License(); - item.setName((String) hash.get("name")); - item.setIdentifier((String) hash.get("identifier")); - item.setUrl((String) hash.get("url")); - return item; - }) - .toList(); - } - - private static List> parse( - DetailNode node, Predicate filter, String path) { - var values = new ArrayList(); - javaDocTag( - node, - filter, - (tag, value) -> { - values.add(tag.getText()); // .substring(1)); - values.add(value); - }); - return JavaDocObjectParser.parse(values).stream() - .map(hash -> path == null ? hash : (Map) hash.get(path)) - .toList(); - } - - public static Map throwList(DetailNode node) { - var result = new LinkedHashMap(); - javaDocTag( - node, - THROWS, - (tag, text) -> { - var statusCode = - tree(tag) - .filter(javadocToken(JavadocCommentsTokenTypes.DESCRIPTION)) - .findFirst() - .flatMap( - it -> - tree(it) - .filter(tagName -> tagName.getText().equals("code")) - .map(DetailNode::getParent) - .map(DetailNode::getParent) - .flatMap( - start -> - children(start) - .filter( - javadocToken(JavadocCommentsTokenTypes.HTML_CONTENT))) - .flatMap( - htmlTag -> - children(htmlTag) - .filter(javadocToken(JavadocCommentsTokenTypes.TEXT)) - .findFirst() - .stream()) - .map(DetailNode::getText) - .map( - value -> { - try { - return Integer.parseInt(value); - } catch (NumberFormatException e) { - return null; - } - }) - .filter(Objects::nonNull) - .filter(code -> code >= 400 && code <= 600) - .map(StatusCode::valueOf) - .findFirst()) - .orElse(null); - if (statusCode != null) { - if (text == null || text.trim().isEmpty()) { - text = statusCode.reason(); - } else { - text = statusCode.reason() + ": " + text; - } - var throwsDoc = new ApiResponse(); - throwsDoc.setDescription(text); - result.putIfAbsent(statusCode, throwsDoc); - } - }); - return result; - } - - public static Map extensions(DetailNode node) { - return parse(node, EXTENSION, null).stream().findFirst().orElse(Map.of()); - } - - public static List tags(DetailNode node) { - var tags = - parse(node, TAG_SHORT, null).stream() - .map(hash -> hash.get("tag").toString().trim()) - .map( - value -> { - // look first space - var indexOf = value.indexOf('.'); - String name; - String description; - if (indexOf > 0) { - name = value.substring(0, indexOf).trim(); - description = value.substring(indexOf + 1).trim(); - } else { - name = value; - description = null; - } - return createTag(name, description); - }) - .collect(Collectors.toList()); - parse(node, TAG, "tag").stream() - .map(hash -> createTag((String) hash.get("name"), (String) hash.get("description"))) - .forEach(tags::add); - return tags; - } - - private static Tag createTag(String tagName, String tagDescription) { - Tag tag = new Tag(); - tag.setName(tagName); - tag.setDescription(tagDescription); - return tag; - } - - public static void javaDocTag( - DetailNode tree, Predicate filter, Consumer2 consumer) { - javaDocTag(tree, filter, (tag, value, text) -> consumer.accept(tag, text)); - } - - public static void javaDocTag( - DetailNode tree, - Predicate filter, - Consumer3 consumer) { - if (tree != JavaDocNode.EMPTY_NODE) { - for (var tag : - tree(tree).filter(javadocToken(JavadocCommentsTokenTypes.JAVADOC_BLOCK_TAG)).toList()) { - var tagName = tree(tag).filter(filter).findFirst().orElse(null); - if (tagName != null) { - var tagValue = - tree(tag) - .filter(javadocToken(JavadocCommentsTokenTypes.DESCRIPTION)) - .findFirst() - .orElse(null); - var tagText = tagValue == null ? null : getText(children(tagValue).toList(), true); - consumer.accept(tagName, tagValue, tagText); - } - } - } - } - - public static String operationId(DetailNode javadoc) { - var operationId = new ArrayList(); - javaDocTag( - javadoc, - OPERATION_ID, - (tag, value, text) -> { - operationId.add(text); - }); - return operationId.isEmpty() ? null : operationId.getFirst(); - } -} diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/ClassDoc.java b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/ClassDoc.java similarity index 90% rename from modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/ClassDoc.java rename to modules/jooby-javadoc/src/main/java/io/jooby/javadoc/ClassDoc.java index 56abfc486e..e2b97ac71b 100644 --- a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/ClassDoc.java +++ b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/ClassDoc.java @@ -3,10 +3,10 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.internal.javadoc; +package io.jooby.javadoc; -import static io.jooby.internal.javadoc.JavaDocStream.*; -import static io.jooby.internal.javadoc.JavaDocSupport.getClassName; +import static io.jooby.javadoc.JavaDocStream.*; +import static io.jooby.javadoc.JavaDocSupport.getClassName; import java.util.*; import java.util.stream.Collectors; @@ -17,19 +17,15 @@ import com.puppycrawl.tools.checkstyle.api.JavadocCommentsTokenTypes; import com.puppycrawl.tools.checkstyle.api.TokenTypes; import com.puppycrawl.tools.checkstyle.utils.TokenUtil; -import io.swagger.v3.oas.models.info.Contact; -import io.swagger.v3.oas.models.info.License; -import io.swagger.v3.oas.models.security.SecurityScheme; -import io.swagger.v3.oas.models.servers.Server; public class ClassDoc extends JavaDocNode { private final Map fields = new LinkedHashMap<>(); private final Map methods = new LinkedHashMap<>(); private final Map scripts = new LinkedHashMap<>(); - private final List servers; - private final List contact; - private final List license; - private final Map securitySchemes; + private final List> servers; + private final List> contact; + private final List> license; + private final Map> securitySchemes; public ClassDoc(JavaDocParser ctx, DetailAST node, DetailAST javaDoc) { super(ctx, node, javaDoc); @@ -44,19 +40,19 @@ public ClassDoc(JavaDocParser ctx, DetailAST node, DetailAST javaDoc) { this.securitySchemes = JavaDocTag.securitySchemes(this.javadoc); } - public Map getSecuritySchemes() { + public Map> getSecuritySchemes() { return securitySchemes; } - public List getServers() { + public List> getServers() { return servers; } - public List getContact() { + public List> getContact() { return contact; } - public List getLicense() { + public List> getLicense() { return license; } @@ -116,9 +112,7 @@ private void defaultRecordMembers() { .filter(javadocToken(JavadocCommentsTokenTypes.PARAMETER_NAME)) .findFirst() .orElse(null); - // name is never null bc previous filter Objects.requireNonNull(name, "name is null"); - /* Virtual Field */ var memberDoc = tree(tag) .filter(javadocToken(JavadocCommentsTokenTypes.DESCRIPTION)) @@ -128,7 +122,6 @@ private void defaultRecordMembers() { new FieldDoc( context, createVirtualMember(name.getText(), TokenTypes.VARIABLE_DEF), memberDoc); addField(field); - /* Virtual method */ var method = new MethodDoc( context, @@ -142,7 +135,6 @@ private void defaultRecordMembers() { private void defaultEnumMembers() { for (var constant : tree(node).filter(tokens(TokenTypes.ENUM_CONSTANT_DEF)).toList()) { - /* Virtual Field */ var name = tree(constant) .filter(tokens(TokenTypes.IDENT)) diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/ContentSplitter.java b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/ContentSplitter.java similarity index 98% rename from modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/ContentSplitter.java rename to modules/jooby-javadoc/src/main/java/io/jooby/javadoc/ContentSplitter.java index 2c956f36b7..7a06b31631 100644 --- a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/ContentSplitter.java +++ b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/ContentSplitter.java @@ -3,9 +3,9 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.internal.javadoc; +package io.jooby.javadoc; -public class ContentSplitter { +class ContentSplitter { public record ContentResult(String summary, String description) {} diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/DocTag.java b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/DocTag.java new file mode 100644 index 0000000000..23fce62583 --- /dev/null +++ b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/DocTag.java @@ -0,0 +1,8 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.javadoc; + +public record DocTag(String name, String description) {} diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/FieldDoc.java b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/FieldDoc.java similarity index 95% rename from modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/FieldDoc.java rename to modules/jooby-javadoc/src/main/java/io/jooby/javadoc/FieldDoc.java index abab36a2ef..a68bd95bb4 100644 --- a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/FieldDoc.java +++ b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/FieldDoc.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.internal.javadoc; +package io.jooby.javadoc; import com.puppycrawl.tools.checkstyle.api.DetailAST; import com.puppycrawl.tools.checkstyle.api.DetailNode; diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocNode.java b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocNode.java similarity index 94% rename from modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocNode.java rename to modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocNode.java index f234b4bfce..063c85fcc0 100644 --- a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocNode.java +++ b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocNode.java @@ -3,9 +3,9 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.internal.javadoc; +package io.jooby.javadoc; -import static io.jooby.internal.javadoc.JavaDocStream.javadocToken; +import static io.jooby.javadoc.JavaDocStream.javadocToken; import java.util.HashSet; import java.util.List; @@ -17,7 +17,6 @@ import com.puppycrawl.tools.checkstyle.api.DetailAST; import com.puppycrawl.tools.checkstyle.api.DetailNode; import com.puppycrawl.tools.checkstyle.api.JavadocCommentsTokenTypes; -import io.swagger.v3.oas.models.tags.Tag; public class JavaDocNode { private static final Predicate JAVADOC_TAG = @@ -27,7 +26,7 @@ public class JavaDocNode { protected final DetailAST node; protected final DetailNode javadoc; private final Map extensions; - private final List tags; + private final List tags; public JavaDocNode(JavaDocParser ctx, DetailAST node, DetailAST comment) { this(ctx, node, toJavaDocNode(comment)); @@ -42,7 +41,7 @@ protected JavaDocNode(JavaDocParser ctx, DetailAST node, DetailNode javadoc) { } static DetailNode toJavaDocNode(DetailAST node) { - return node == EMPTY_AST + return (node == null || node == EMPTY_AST) ? EMPTY_NODE : new JavadocDetailNodeParser().parseJavadocComment(node).getTree(); } @@ -60,7 +59,7 @@ public String getSummary() { return summary.isEmpty() ? null : summary; } - public List getTags() { + public List getTags() { return tags; } @@ -96,7 +95,6 @@ protected Object toExamples(String text) { var clean = codeExample.substring(1, codeExample.length() - 1); var result = JavaDocObjectParser.parseJson(clean); if (result.equals(codeExample)) { - // Like a primitive/basic example return List.of(result); } return result; @@ -121,9 +119,7 @@ protected static String getText(List nodes, boolean stripLeading) { visited.add(next); } } else if (node.getType() == JavadocCommentsTokenTypes.TAG_NAME) { - //

? if (node.getText().equals("p")) { - // keep so we can split summary from description builder.append("

"); } } @@ -138,6 +134,7 @@ protected static String toString(DetailNode node) { public static final DetailNode EMPTY_NODE = new DetailNode() { + /* Omitted for brevity, keep your original EMPTY_NODE impl */ @Override public int getType() { return JavadocCommentsTokenTypes.JAVADOC_CONTENT; @@ -181,6 +178,7 @@ public DetailNode getPreviousSibling() { public static final DetailAST EMPTY_AST = new DetailAST() { + /* Omitted for brevity, keep your original EMPTY_AST impl */ @Override public int getChildCount() { return 0; diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocObjectParser.java b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocObjectParser.java similarity index 99% rename from modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocObjectParser.java rename to modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocObjectParser.java index 8c7868582f..6d2c16ca55 100644 --- a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocObjectParser.java +++ b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocObjectParser.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.internal.javadoc; +package io.jooby.javadoc; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -16,7 +16,7 @@ * rows, dot-separated path expressions, repeated paths, and values explicitly wrapped in square * brackets '[]'. The structure can be augmented by subsequent rows. */ -public class JavaDocObjectParser { +class JavaDocObjectParser { /** * A parser for a non-standard JSON-like syntax where string keys and values are not enclosed in diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocParser.java similarity index 85% rename from modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java rename to modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocParser.java index f629455de2..6dbbf7e844 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocParser.java +++ b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocParser.java @@ -3,13 +3,13 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.internal.openapi.javadoc; +package io.jooby.javadoc; import static com.puppycrawl.tools.checkstyle.JavaParser.parseFile; -import static io.jooby.SneakyThrows.throwingFunction; -import static io.jooby.internal.openapi.javadoc.JavaDocStream.*; -import static io.jooby.internal.openapi.javadoc.JavaDocStream.tokens; -import static io.jooby.internal.openapi.javadoc.JavaDocSupport.*; +import static io.jooby.javadoc.JavaDocStream.*; +import static io.jooby.javadoc.JavaDocStream.tokens; +import static io.jooby.javadoc.JavaDocSupport.*; +import static io.jooby.javadoc.Utils.throwingFunction; import static java.util.Optional.ofNullable; import java.io.File; @@ -26,11 +26,11 @@ import com.puppycrawl.tools.checkstyle.api.TokenTypes; import com.puppycrawl.tools.checkstyle.utils.TokenUtil; import com.puppycrawl.tools.checkstyle.utils.XpathUtil; -import io.jooby.Context; -import io.jooby.Router; public class JavaDocParser { + List METHODS = List.of("GET", "POST", "PUT", "DELETE", "PATCH"); + public static final JavaDocParser NOOP = new JavaDocParser(List.of()); private record ScriptRef(String operationId, DetailAST comment) {} @@ -174,7 +174,7 @@ private int scripts( if (scriptComment != JavaDocNode.EMPTY_AST) { counter.incrementAndGet(); } - if (Router.METHODS.contains(callName.toUpperCase())) { + if (METHODS.contains(callName.toUpperCase())) { pathLiteral(script) .ifPresent( pattern -> { @@ -255,7 +255,7 @@ private ScriptRef resolveFromMethodRef(ClassDoc classDoc, DetailAST methodRef) { className = classDoc.getName(); } else { // resolve className - className = toQualifiedName(classDoc.node, referenceOwner); + className = toQualifiedName(classDoc.getNode(), referenceOwner); scope = resolveType(className); if (scope == JavaDocNode.EMPTY_AST) { // not found @@ -285,8 +285,8 @@ private ScriptRef resolveFromMethodRef(ClassDoc classDoc, DetailAST methodRef) { .flatMap(p -> children(p).filter(tokens(TokenTypes.TYPE)).findFirst()) .filter( type -> - toQualifiedName(classDoc.node, getQualifiedName(type)) - .equals(Context.class.getName())) + toQualifiedName(classDoc.getNode(), getQualifiedName(type)) + .equals("io.jooby.Context")) .isPresent()) .findFirst() .orElseThrow( @@ -318,9 +318,9 @@ private static Optional pathLiteral(DetailAST script) { private String computePath(String prefix, String pattern) { if (prefix == null) { - return Router.normalizePath(pattern); + return normalizePath(pattern); } - return Router.noTrailingSlash(Router.normalizePath(prefix + pattern)); + return noTrailingSlash(normalizePath(prefix + pattern)); } private DetailAST resolveType(String typeName) { @@ -335,4 +335,56 @@ private Optional lookup(Path path) { .filter(Files::exists) .findFirst(); } + + /** + * Strip trailing slashes. + * + * @param path Path to process. + * @return Path without trailing slashes. + */ + private static String noTrailingSlash(String path) { + StringBuilder buff = new StringBuilder(path); + int i = buff.length() - 1; + while (i > 0 && buff.charAt(i) == '/') { + buff.setLength(i); + i -= 1; + } + if (path.length() != buff.length()) { + return buff.toString(); + } + return path; + } + + /** + * Normalize a path by removing consecutive /(slashes). + * + * @param path Path to process. + * @return Safe path pattern. + */ + private static String normalizePath(String path) { + if (path == null || path.length() == 0 || path.equals("/")) { + return "/"; + } + int len = path.length(); + boolean modified = false; + int p = 0; + char[] buff = new char[len + 1]; + if (path.charAt(0) != '/') { + buff[p++] = '/'; + modified = true; + } + for (int i = 0; i < path.length(); i++) { + char ch = path.charAt(i); + if (ch != '/') { + buff[p++] = ch; + } else if (i == 0 || path.charAt(i - 1) != '/') { + buff[p++] = ch; + } else { + // double slash + modified = true; + } + } + // creates string? + return modified ? new String(buff, 0, p) : path; + } } diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocStream.java b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocStream.java similarity index 98% rename from modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocStream.java rename to modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocStream.java index f6007a923c..6bfdc5b033 100644 --- a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocStream.java +++ b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocStream.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.internal.javadoc; +package io.jooby.javadoc; import java.util.*; import java.util.function.Function; @@ -14,7 +14,7 @@ import com.puppycrawl.tools.checkstyle.api.DetailAST; import com.puppycrawl.tools.checkstyle.api.DetailNode; -public final class JavaDocStream { +final class JavaDocStream { public static Predicate tokens(Integer... types) { return tokens(Set.of(types)); diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocSupport.java b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocSupport.java similarity index 96% rename from modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocSupport.java rename to modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocSupport.java index bf18f08ed6..1088238689 100644 --- a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/JavaDocSupport.java +++ b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocSupport.java @@ -3,9 +3,9 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.internal.javadoc; +package io.jooby.javadoc; -import static io.jooby.internal.javadoc.JavaDocStream.*; +import static io.jooby.javadoc.JavaDocStream.*; import java.util.List; import java.util.function.Predicate; @@ -16,7 +16,7 @@ import com.puppycrawl.tools.checkstyle.api.TokenTypes; import com.puppycrawl.tools.checkstyle.utils.TokenUtil; -public class JavaDocSupport { +class JavaDocSupport { public static final Predicate TYPES = tokens( @@ -104,6 +104,8 @@ yield children(cu) .filter(qualifiedName -> qualifiedName.endsWith("." + typeName)) .findFirst() .orElseGet(() -> String.join(".", getPackageName(classDef), typeName)); + } else { + yield getClassName(classDef); } } // Already qualified. diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocTag.java b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocTag.java new file mode 100644 index 0000000000..0a74fdf717 --- /dev/null +++ b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/JavaDocTag.java @@ -0,0 +1,321 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.javadoc; + +import static io.jooby.javadoc.JavaDocNode.getText; +import static io.jooby.javadoc.JavaDocStream.*; + +import java.util.*; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.puppycrawl.tools.checkstyle.api.DetailNode; +import com.puppycrawl.tools.checkstyle.api.JavadocCommentsTokenTypes; + +class JavaDocTag { + private static final Predicate TAG_SHORT = it -> it.getText().equals("tag"); + private static final Predicate TAG = it -> it.getText().startsWith("tag."); + private static final Predicate SERVER = it -> it.getText().startsWith("server."); + private static final Predicate SECURITY = + it -> it.getText().equals("security") || it.getText().equals("securityRequirement"); + private static final Predicate SECURITY_SCHEME = + it -> it.getText().startsWith("securityScheme."); + private static final Predicate CONTACT = it -> it.getText().startsWith("contact."); + private static final Predicate LICENSE = it -> it.getText().startsWith("license."); + private static final Predicate OPERATION_ID = + it -> it.getText().equals("operationId"); + private static final Predicate EXTENSION = it -> it.getText().startsWith("x-"); + private static final Predicate THROWS = + it -> tree(it).anyMatch(javadocToken(JavadocCommentsTokenTypes.THROWS_BLOCK_TAG)); + private static final Predicate PARAM = + it -> tree(it).anyMatch(javadocToken(JavadocCommentsTokenTypes.PARAM_BLOCK_TAG)); + + public static Map getParametersDoc(DetailNode node) { + var parameters = new LinkedHashMap(); + javaDocTag( + node, + PARAM, + (tag, value) -> { + tree(tag) + .filter(javadocToken(JavadocCommentsTokenTypes.PARAMETER_NAME)) + .findFirst() + .map(DetailNode::getText) + .ifPresent( + name -> + tree(tag) + .filter(javadocToken(JavadocCommentsTokenTypes.DESCRIPTION)) + .findFirst() + .map(description -> getText(tree(description).toList(), true)) + .ifPresent(text -> parameters.put(name, text))); + }); + return parameters; + } + + public static String getReturnDoc(DetailNode node) { + var text = new StringBuilder(); + javaDocTag( + node, + javadocToken(JavadocCommentsTokenTypes.RETURN_BLOCK_TAG), + (tag, value) -> { + tree(tag.getParent()) + .filter(javadocToken(JavadocCommentsTokenTypes.DESCRIPTION)) + .findFirst() + .map(description -> getText(tree(description).toList(), true)) + .ifPresent(text::append); + }); + var result = text.toString().trim(); + return result.isEmpty() ? null : result; + } + + public static List>> securityRequirement(DetailNode node) { + return parse(node, SECURITY, null).stream() + .map( + hash -> + hash.containsKey("security") + ? hash.get("security") + : hash.get("securityRequirement")) + .map(Object::toString) + .map(String::trim) + .map( + value -> { + // look first space + var indexOf = value.indexOf(' '); + String key; + String scopes; + if (indexOf > 0) { + key = value.substring(0, indexOf).trim(); + scopes = value.substring(indexOf + 1).trim(); + } else { + key = value; + scopes = ""; + } + if (scopes.startsWith("[") && scopes.endsWith("]")) { + scopes = scopes.substring(1, scopes.length() - 1); + } + + var scopeList = + Stream.of(scopes.split(",")).map(String::trim).filter(s -> !s.isEmpty()).toList(); + + return Map.of(key, scopeList); + }) + .toList(); + } + + public static Map> securitySchemes(DetailNode node) { + var result = new LinkedHashMap>(); + parse(node, SECURITY_SCHEME, "securityScheme") + .forEach( + hash -> { + // Extract the identifier (e.g., "myOauth2Security") + var name = (String) hash.get("name"); + if (name != null) { + result.put(name, (Map) hash); + } + }); + return result; + } + + public static List> servers(DetailNode node) { + return parse(node, SERVER, "server"); + } + + public static List> contacts(DetailNode node) { + return parse(node, CONTACT, "contact"); + } + + public static List> license(DetailNode node) { + return parse(node, LICENSE, "license"); + } + + @SuppressWarnings("unchecked") + private static List> parse( + DetailNode node, Predicate filter, String path) { + var values = new ArrayList(); + javaDocTag( + node, + filter, + (tag, value) -> { + values.add(tag.getText()); + values.add(value); + }); + return JavaDocObjectParser.parse(values).stream() + .map(hash -> path == null ? hash : (Map) hash.get(path)) + .toList(); + } + + public static Map throwList(DetailNode node) { + var result = new LinkedHashMap(); + javaDocTag( + node, + THROWS, + (tag, text) -> { + // Search the entire description subtree for a TEXT node containing exactly a 3-digit + // number + var statusCode = + tree(tag) + .filter(javadocToken(JavadocCommentsTokenTypes.DESCRIPTION)) + .flatMap(JavaDocStream::tree) // Flatten all nested elements (including HTML tags) + .filter(javadocToken(JavadocCommentsTokenTypes.TEXT)) + .map(DetailNode::getText) + .map(String::trim) + .filter(s -> s.matches("\\d{3}")) // Only match isolated status codes like "400" + .map(Integer::parseInt) + .filter(code -> code >= 400 && code <= 600) + .findFirst() + .orElse(null); + + if (statusCode != null) { + String finalText = reason(statusCode); + if (text != null && !text.trim().isEmpty()) { + finalText += ": " + text; + } + result.putIfAbsent(statusCode, finalText); + } + }); + return result; + } + + public static Map extensions(DetailNode node) { + return parse(node, EXTENSION, null).stream().findFirst().orElse(Map.of()); + } + + public static List tags(DetailNode node) { + var tags = + parse(node, TAG_SHORT, null).stream() + .map(hash -> hash.get("tag").toString().trim()) + .map( + value -> { + var indexOf = value.indexOf('.'); + String name; + String description; + if (indexOf > 0) { + name = value.substring(0, indexOf).trim(); + description = value.substring(indexOf + 1).trim(); + } else { + name = value; + description = null; + } + return new DocTag(name, description); + }) + .collect(Collectors.toList()); + + parse(node, TAG, "tag").stream() + .map(hash -> new DocTag((String) hash.get("name"), (String) hash.get("description"))) + .forEach(tags::add); + return tags; + } + + public static void javaDocTag( + DetailNode tree, Predicate filter, Utils.Consumer2 consumer) { + javaDocTag(tree, filter, (tag, value, text) -> consumer.accept(tag, text)); + } + + public static void javaDocTag( + DetailNode tree, + Predicate filter, + Utils.Consumer3 consumer) { + if (tree != JavaDocNode.EMPTY_NODE) { + for (var tag : + tree(tree).filter(javadocToken(JavadocCommentsTokenTypes.JAVADOC_BLOCK_TAG)).toList()) { + var tagName = tree(tag).filter(filter).findFirst().orElse(null); + if (tagName != null) { + var tagValue = + tree(tag) + .filter(javadocToken(JavadocCommentsTokenTypes.DESCRIPTION)) + .findFirst() + .orElse(null); + var tagText = tagValue == null ? null : getText(children(tagValue).toList(), true); + consumer.accept(tagName, tagValue, tagText); + } + } + } + } + + public static String operationId(DetailNode javadoc) { + var operationId = new ArrayList(); + javaDocTag( + javadoc, + OPERATION_ID, + (tag, value, text) -> { + operationId.add(text); + }); + return operationId.isEmpty() ? null : operationId.getFirst(); + } + + /** + * Return the reason phrase of the given HTTP status code. + * + * @param statusCode the numeric value of the status code + * @return the reason phrase of the status code + */ + public static String reason(int statusCode) { + return switch (statusCode) { + case 100 -> "Continue"; + case 101 -> "Switching Protocols"; + case 102 -> "Processing"; + case 103 -> "Checkpoint"; + case 200 -> "Success"; + case 201 -> "Created"; + case 202 -> "Accepted"; + case 203 -> "Non-Authoritative Information"; + case 204 -> "No Content"; + case 205 -> "Reset Content"; + case 206 -> "Partial Content"; + case 207 -> "Multi-StatusCode"; + case 208 -> "Already Reported"; + case 226 -> "IM Used"; + case 300 -> "Multiple Choices"; + case 301 -> "Moved Permanently"; + case 302 -> "Found"; + case 303 -> "See Other"; + case 304 -> "Not Modified"; + case 305 -> "Use Proxy"; + case 307 -> "Temporary Redirect"; + case 308 -> "Resume Incomplete"; + case 400 -> "Bad Request"; + case 401 -> "Unauthorized"; + case 402 -> "Payment Required"; + case 403 -> "Forbidden"; + case 404 -> "Not Found"; + case 405 -> "Method Not Allowed"; + case 406 -> "Not Acceptable"; + case 407 -> "Proxy Authentication Required"; + case 408 -> "Request Timeout"; + case 409 -> "Conflict"; + case 410 -> "Gone"; + case 411 -> "Length Required"; + case 412 -> "Precondition Failed"; + case 413 -> "Request Entity Too Large"; + case 414 -> "Request-URI Too Long"; + case 415 -> "Unsupported Media Type"; + case 416 -> "Requested range not satisfiable"; + case 417 -> "Expectation Failed"; + case 418 -> "I'm a teapot"; + case 422 -> "Unprocessable Entity"; + case 423 -> "Locked"; + case 424 -> "Failed Dependency"; + case 426 -> "Upgrade Required"; + case 428 -> "Precondition Required"; + case 429 -> "Too Many Requests"; + case 431 -> "Request Header Fields Too Large"; + case 499 -> "The client aborted the request before completion"; + case 500 -> "Server Error"; + case 501 -> "Not Implemented"; + case 502 -> "Bad Gateway"; + case 503 -> "Service Unavailable"; + case 504 -> "Gateway Timeout"; + case 505 -> "HTTP Version not supported"; + case 506 -> "Variant Also Negotiates"; + case 507 -> "Insufficient Storage"; + case 508 -> "Loop Detected"; + case 509 -> "Bandwidth Limit Exceeded"; + case 510 -> "Not Extended"; + case 511 -> "Network Authentication Required"; + default -> Integer.toString(statusCode); + }; + } +} diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/MethodDoc.java b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/MethodDoc.java similarity index 88% rename from modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/MethodDoc.java rename to modules/jooby-javadoc/src/main/java/io/jooby/javadoc/MethodDoc.java index 0a172ad8cb..11762f7959 100644 --- a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/MethodDoc.java +++ b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/MethodDoc.java @@ -3,9 +3,9 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.internal.javadoc; +package io.jooby.javadoc; -import static io.jooby.internal.javadoc.JavaDocStream.*; +import static io.jooby.javadoc.JavaDocStream.*; import java.util.ArrayList; import java.util.List; @@ -15,15 +15,12 @@ import com.puppycrawl.tools.checkstyle.api.DetailNode; import com.puppycrawl.tools.checkstyle.api.JavadocCommentsTokenTypes; import com.puppycrawl.tools.checkstyle.api.TokenTypes; -import io.jooby.StatusCode; -import io.swagger.v3.oas.models.responses.ApiResponse; -import io.swagger.v3.oas.models.security.SecurityRequirement; public class MethodDoc extends JavaDocNode { private Map parameters; - private List securityRequeriments; + private List>> securityRequeriments; private String operationId; - private Map throwList; + private Map throwList; private List parameterTypes = null; private String returnDoc; @@ -52,7 +49,7 @@ public void setOperationId(String operationId) { this.operationId = operationId; } - public List getSecurityRequirements() { + public List>> getSecurityRequirements() { return securityRequeriments; } @@ -119,7 +116,7 @@ public Object getReturnExample() { return toExamples(returnDoc); } - public Map getThrows() { + public Map getThrows() { return throwList; } } diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/PathDoc.java b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/PathDoc.java similarity index 90% rename from modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/PathDoc.java rename to modules/jooby-javadoc/src/main/java/io/jooby/javadoc/PathDoc.java index f9ff8fcc01..4a6ad7feba 100644 --- a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/PathDoc.java +++ b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/PathDoc.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.internal.javadoc; +package io.jooby.javadoc; import com.puppycrawl.tools.checkstyle.api.DetailAST; diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/ScriptDoc.java b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/ScriptDoc.java similarity index 95% rename from modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/ScriptDoc.java rename to modules/jooby-javadoc/src/main/java/io/jooby/javadoc/ScriptDoc.java index bbd848b0d0..bd04be9532 100644 --- a/modules/jooby-javadoc/src/main/java/io/jooby/internal/javadoc/ScriptDoc.java +++ b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/ScriptDoc.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.internal.javadoc; +package io.jooby.javadoc; import com.puppycrawl.tools.checkstyle.api.DetailAST; diff --git a/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/Utils.java b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/Utils.java new file mode 100644 index 0000000000..c0e8b7b007 --- /dev/null +++ b/modules/jooby-javadoc/src/main/java/io/jooby/javadoc/Utils.java @@ -0,0 +1,171 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.javadoc; + +public class Utils { + /** + * Two argument version of {@link java.util.function.BiConsumer}. + * + *

This class rethrow any exception using the {@link #propagate(Throwable)} technique. + * + * @param Input type. + * @param Input type. + */ + @FunctionalInterface + public interface Consumer2 extends java.util.function.BiConsumer { + /** + * Performs this operation on the given argument. + * + * @param v1 Argument. + * @param v2 Argument. + * @throws Exception If something goes wrong. + */ + void tryAccept(V1 v1, V2 v2) throws Exception; + + /** + * Performs this operation on the given argument. + * + * @param v1 Argument. + * @param v2 Argument. + */ + default void accept(V1 v1, V2 v2) { + try { + tryAccept(v1, v2); + } catch (Exception x) { + throw propagate(x); + } + } + } + + /** + * Three argument version of {@link java.util.function.Consumer}. + * + *

This class rethrow any exception using the {@link #propagate(Throwable)} technique. + * + * @param Input type. + * @param Input type. + * @param Input type. + */ + @FunctionalInterface + public interface Consumer3 { + /** + * Performs this operation on the given argument. + * + * @param v1 Argument. + * @param v2 Argument. + * @param v3 Argument. + * @throws Exception If something goes wrong. + */ + void tryAccept(V1 v1, V2 v2, V3 v3) throws Exception; + + /** + * Performs this operation on the given arguments and throw any exception using {@link + * #propagate(Throwable)} method. + * + * @param v1 Argument. + * @param v2 Argument. + * @param v3 Argument. + */ + default void accept(V1 v1, V2 v2, V3 v3) { + try { + tryAccept(v1, v2, v3); + } catch (Exception x) { + throw propagate(x); + } + } + } + + /** + * 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"); + } + + throw sneakyThrow0(x); + } + + /** + * Make a checked exception un-checked and rethrow it. + * + * @param x Exception to throw. + * @param Exception type. + * @throws E Exception to throw. + */ + @SuppressWarnings("unchecked") + private static E sneakyThrow0(final Throwable x) throws E { + throw (E) x; + } + + /** + * Throwable version of {@link java.util.function.Function}. + * + *

The {@link #apply(Object)} method throws checked exceptions using {@link + * #propagate(Throwable)} method. + * + * @param Input type. + * @param Output type. + */ + @FunctionalInterface + public interface Function extends java.util.function.Function { + /** + * Apply this function to the given argument and produces a result. + * + * @param value Input argument. + * @return Result. + * @throws Exception If something goes wrong. + */ + R tryApply(V value) throws Exception; + + /** + * Apply this function to the given argument and produces a result. + * + * @param v Input argument. + * @return Result. + */ + @Override + default R apply(V v) { + try { + return tryApply(v); + } catch (Exception x) { + throw propagate(x); + } + } + } + + /** + * Factory method for {@link Function} and {@link java.util.function.Function}. + * + * @param fn Function. + * @param Input value. + * @param Result value. + * @return Same supplier. + */ + public static Function throwingFunction(Function fn) { + return fn; + } +} diff --git a/modules/jooby-javadoc/src/main/java/module-info.java b/modules/jooby-javadoc/src/main/java/module-info.java new file mode 100644 index 0000000000..304a2635b2 --- /dev/null +++ b/modules/jooby-javadoc/src/main/java/module-info.java @@ -0,0 +1,5 @@ +module io.jooby.javadoc { + requires com.puppycrawl.tools.checkstyle; + + exports io.jooby.javadoc; +} diff --git a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/javadoc/ContentSplitterTest.java b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/ContentSplitterTest.java similarity index 98% rename from modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/javadoc/ContentSplitterTest.java rename to modules/jooby-javadoc/src/test/java/io/jooby/javadoc/ContentSplitterTest.java index 39f2ff3f8f..53636fb0ba 100644 --- a/modules/jooby-openapi/src/test/java/io/jooby/internal/openapi/javadoc/ContentSplitterTest.java +++ b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/ContentSplitterTest.java @@ -3,9 +3,9 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.internal.openapi.javadoc; +package io.jooby.javadoc; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; import org.junit.jupiter.api.Test; diff --git a/modules/jooby-openapi/src/test/java/javadoc/JavaDocObjectParserTest.java b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/JavaDocObjectParserTest.java similarity index 97% rename from modules/jooby-openapi/src/test/java/javadoc/JavaDocObjectParserTest.java rename to modules/jooby-javadoc/src/test/java/io/jooby/javadoc/JavaDocObjectParserTest.java index 479f6952d6..0ae8d41a18 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/JavaDocObjectParserTest.java +++ b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/JavaDocObjectParserTest.java @@ -3,9 +3,10 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package javadoc; +package io.jooby.javadoc; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; import java.util.Map; @@ -13,8 +14,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import io.jooby.internal.openapi.javadoc.JavaDocObjectParser; - /** * JUnit 5 test class for ListToMapParser. This class provides comprehensive tests for all specified * use cases, including structure augmentation by subsequent rows. diff --git a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/JavaDocParserTest.java similarity index 92% rename from modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java rename to modules/jooby-javadoc/src/test/java/io/jooby/javadoc/JavaDocParserTest.java index a8dd91ae0a..2de0a554e1 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/JavaDocParserTest.java +++ b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/JavaDocParserTest.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package javadoc; +package io.jooby.javadoc; import static org.junit.jupiter.api.Assertions.*; @@ -11,20 +11,18 @@ import java.nio.file.Paths; import java.util.List; import java.util.function.Consumer; -import javadoc.input.EnumDoc; import org.junit.jupiter.api.Test; -import io.jooby.SneakyThrows; -import io.jooby.internal.openapi.javadoc.*; -import issues.i3729.api.Book; +import io.jooby.javadoc.input.*; +import io.jooby.javadoc.input.sub.Base; public class JavaDocParserTest { @Test public void inheritance() throws Exception { withDoc( - javadoc.input.Subclass.class, + Subclass.class, doc -> { assertEquals("Subclass summary.", doc.getSummary()); assertEquals("Subclass description.", doc.getDescription()); @@ -35,7 +33,7 @@ public void inheritance() throws Exception { }); withDoc( - javadoc.input.sub.Base.class, + Base.class, doc -> { assertEquals("Base summary.", doc.getSummary()); assertEquals("Base description.", doc.getDescription()); @@ -49,7 +47,7 @@ public void inheritance() throws Exception { @Test public void multiline() throws Exception { withDoc( - javadoc.input.MultilineComment.class, + MultilineComment.class, doc -> { assertNull(doc.getSummary()); assertNull(doc.getDescription()); @@ -71,10 +69,10 @@ public void multiline() throws Exception { @Test public void lambdaDoc() throws Exception { withDoc( - javadoc.input.LambdaRefApp.class, + LambdaRefApp.class, doc -> { assertEquals("LambdaRefApp", doc.getSimpleName()); - assertEquals("javadoc.input.LambdaRefApp", doc.getName()); + assertEquals("io.jooby.javadoc.input.LambdaRefApp", doc.getName()); assertEquals("Lambda App.", doc.getSummary()); assertEquals("Using method ref.", doc.getDescription()); @@ -129,10 +127,10 @@ public void lambdaDoc() throws Exception { @Test public void scriptDoc() throws Exception { withDoc( - javadoc.input.ScriptApp.class, + ScriptApp.class, doc -> { assertEquals("ScriptApp", doc.getSimpleName()); - assertEquals("javadoc.input.ScriptApp", doc.getName()); + assertEquals("io.jooby.javadoc.input.ScriptApp", doc.getName()); assertEquals("Script App.", doc.getSummary()); assertEquals("Some description.", doc.getDescription()); @@ -176,7 +174,7 @@ public void scriptDoc() throws Exception { assertEquals("Tree doc.", method.getPath().getDescription()); assertNotNull(method.getPath().getTags()); assertEquals(1, method.getPath().getTags().size()); - assertEquals("Tree", method.getPath().getTags().getFirst().getName()); + assertEquals("Tree", method.getPath().getTags().getFirst().name()); assertEquals("Item doc.", method.getSummary()); assertNull(method.getDescription()); @@ -231,10 +229,10 @@ public void scriptDoc() throws Exception { @Test public void apiDoc() throws Exception { withDoc( - javadoc.input.ApiDoc.class, + ApiDoc.class, doc -> { assertEquals("ApiDoc", doc.getSimpleName()); - assertEquals("javadoc.input.ApiDoc", doc.getName()); + assertEquals("io.jooby.javadoc.input.ApiDoc", doc.getName()); assertEquals("Api summary.", doc.getSummary()); assertEquals( "Proin sit amet lectus interdum, porta libero quis, fringilla metus. Integer viverra" @@ -243,10 +241,10 @@ public void apiDoc() throws Exception { var servers = doc.getServers(); assertEquals(2, servers.size()); - assertEquals("https://api.example.com/v1", servers.get(0).getUrl()); - assertEquals("Production server (uses live data)", servers.get(0).getDescription()); - assertEquals("https://sandbox-api.example.com:8443/v1", servers.get(1).getUrl()); - assertEquals("Sandbox server (uses test data)", servers.get(1).getDescription()); + assertEquals("https://api.example.com/v1", servers.get(0).get("url")); + assertEquals("Production server (uses live data)", servers.get(0).get("description")); + assertEquals("https://sandbox-api.example.com:8443/v1", servers.get(1).get("url")); + assertEquals("Sandbox server (uses test data)", servers.get(1).get("description")); withMethod( doc, @@ -265,7 +263,7 @@ public void apiDoc() throws Exception { withMethod( doc, "search", - List.of("javadoc.input.QueryBeanDoc"), + List.of("io.jooby.javadoc.input.QueryBeanDoc"), method -> { assertEquals("Search database.", method.getSummary()); assertEquals("Search DB", method.getDescription()); @@ -278,7 +276,7 @@ public void apiDoc() throws Exception { withMethod( doc, "recordBean", - List.of("javadoc.input.RecordBeanDoc"), + List.of("io.jooby.javadoc.input.RecordBeanDoc"), method -> { assertEquals("Record database.", method.getSummary()); assertNull(method.getDescription()); @@ -289,7 +287,7 @@ public void apiDoc() throws Exception { withMethod( doc, "enumParam", - List.of("javadoc.input.EnumDoc"), + List.of("io.jooby.javadoc.input.EnumDoc"), method -> { assertEquals("Enum database.", method.getSummary()); assertEquals("Enum doc.", method.getParameterDoc("query")); @@ -305,14 +303,14 @@ public void ignoreStatementComment() throws Exception { @Test public void noDoc() throws Exception { - var result = newParser().parse("javadoc.input.NoDoc"); + var result = newParser().parse("io.jooby.javadoc.input.NoDoc"); assertTrue(result.isEmpty()); } @Test public void noClassDoc() throws Exception { withDoc( - javadoc.input.NoClassDoc.class, + NoClassDoc.class, doc -> { assertNull(doc.getSummary()); assertNull(doc.getDescription()); @@ -354,7 +352,7 @@ public void shouldParseBean() throws Exception { }); withDoc( - javadoc.input.QueryBeanDoc.class, + QueryBeanDoc.class, doc -> { assertEquals("Search options.", doc.getSummary()); assertNull(doc.getDescription()); @@ -383,7 +381,7 @@ public void shouldParseBean() throws Exception { @Test public void shouldRecord() throws Exception { withDoc( - javadoc.input.RecordBeanDoc.class, + RecordBeanDoc.class, doc -> { assertEquals("Record documentation.", doc.getSummary()); assertNull(doc.getDescription()); @@ -413,7 +411,7 @@ public void shouldRecord() throws Exception { @Test public void shouldVerifyJavaDocScope() throws Exception { withDoc( - javadoc.input.ScopeDoc.class, + ScopeDoc.class, doc -> { assertEquals("Class", doc.getSummary()); assertNull(doc.getDescription()); @@ -451,7 +449,7 @@ private void withDoc(Class typeName, Consumer consumer) throws Exce assertFalse(result.isEmpty()); consumer.accept(result.get()); } catch (Throwable cause) { - throw SneakyThrows.propagate(cause); + throw Utils.propagate(cause); } } diff --git a/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/JavadocTestHelper.java b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/JavadocTestHelper.java new file mode 100644 index 0000000000..f637a64fba --- /dev/null +++ b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/JavadocTestHelper.java @@ -0,0 +1,58 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.javadoc; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; + +import com.puppycrawl.tools.checkstyle.JavaParser; +import com.puppycrawl.tools.checkstyle.api.DetailAST; +import com.puppycrawl.tools.checkstyle.api.FileContents; +import com.puppycrawl.tools.checkstyle.api.FileText; +import com.puppycrawl.tools.checkstyle.api.TokenTypes; + +public class JavadocTestHelper { + + public static ParseResult parseCode(String source) throws Exception { + Path tempFile = Files.createTempFile("JoobyTestStub", ".java"); + Files.writeString(tempFile, source, StandardCharsets.UTF_8); + + try { + File file = tempFile.toFile(); + FileText text = new FileText(file, Arrays.asList(source.split("\\r?\\n"))); + FileContents contents = new FileContents(text); + + // CRITICAL: We must use WITH_COMMENTS, otherwise Checkstyle drops the Javadocs! + DetailAST rootAst = JavaParser.parseFile(file, JavaParser.Options.WITH_COMMENTS); + + return new ParseResult(contents, rootAst); + } finally { + Files.deleteIfExists(tempFile); + } + } + + public static DetailAST findToken(DetailAST root, int tokenType) { + if (root == null) return null; + if (root.getType() == tokenType) return root; + + DetailAST child = root.getFirstChild(); + while (child != null) { + DetailAST match = findToken(child, tokenType); + if (match != null) return match; + child = child.getNextSibling(); + } + return null; + } + + public record ParseResult(FileContents contents, DetailAST rootAst) { + public DetailAST getMethodNode() { + return findToken(rootAst, TokenTypes.METHOD_DEF); + } + } +} diff --git a/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/MethodDocTest.java b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/MethodDocTest.java new file mode 100644 index 0000000000..2011ccee8f --- /dev/null +++ b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/MethodDocTest.java @@ -0,0 +1,184 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.javadoc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.puppycrawl.tools.checkstyle.api.DetailAST; +import com.puppycrawl.tools.checkstyle.api.TokenTypes; + +public class MethodDocTest { + + /** + * Helper method to reliably extract the Javadoc block from the parsed method node. Returns + * EMPTY_AST instead of null to prevent Checkstyle parsing exceptions. + */ + private DetailAST getJavadocNode(DetailAST methodNode) { + var node = JavadocTestHelper.findToken(methodNode, TokenTypes.BLOCK_COMMENT_BEGIN); + return node != null ? node : JavaDocNode.EMPTY_AST; + } + + @Test + public void shouldExtractSecurityRequirements() throws Exception { + var source = + """ + package io.jooby.internal.javadoc; + + public class Controller { + /** + * @securityRequirement myOauth [read:users, write:users] + */ + public void myMethod() {} + } + """; + + var result = JavadocTestHelper.parseCode(source); + var methodNode = result.getMethodNode(); + var javadocNode = getJavadocNode(methodNode); + + var doc = new MethodDoc(null, methodNode, javadocNode); + var security = doc.getSecurityRequirements(); + + assertNotNull(security, "Security requirements should not be null"); + assertEquals(1, security.size()); + assertEquals(List.of("read:users", "write:users"), security.getFirst().get("myOauth")); + } + + @Test + public void shouldExtractParameters() throws Exception { + var source = + """ + package io.jooby.internal.javadoc; + + public class Controller { + /** + * @param userId The ID of the user. + * @param filter An optional filter to apply. + */ + public void myMethod(String userId, String filter) {} + } + """; + + var result = JavadocTestHelper.parseCode(source); + var methodNode = result.getMethodNode(); + var javadocNode = getJavadocNode(methodNode); + + var doc = new MethodDoc(null, methodNode, javadocNode); + + assertEquals("The ID of the user.", doc.getParameterDoc("userId")); + assertEquals("An optional filter to apply.", doc.getParameterDoc("filter")); + assertNull(doc.getParameterDoc("missingParameter")); + } + + @Test + public void shouldExtractReturnDoc() throws Exception { + var source = + """ + package io.jooby.internal.javadoc; + import java.util.List; + + public class Controller { + /** + * @return A list of active users in the system. + */ + public List myMethod() { return null; } + } + """; + + var result = JavadocTestHelper.parseCode(source); + var methodNode = result.getMethodNode(); + var javadocNode = getJavadocNode(methodNode); + + var doc = new MethodDoc(null, methodNode, javadocNode); + + assertEquals("A list of active users in the system.", doc.getReturnDoc()); + } + + @Test + public void shouldExtractThrowsWithStatusCode() throws Exception { + var source = + """ + package io.jooby.internal.javadoc; + + public class Controller { + /** + * @throws IllegalArgumentException 400 if the input is invalid. + * @throws IllegalStateException 409 Conflict occurred. + */ + public void myMethod() {} + } + """; + + var result = JavadocTestHelper.parseCode(source); + var methodNode = result.getMethodNode(); + var javadocNode = getJavadocNode(methodNode); + + var doc = new MethodDoc(null, methodNode, javadocNode); + var throwsMap = doc.getThrows(); + + assertNotNull(throwsMap); + assertEquals(2, throwsMap.size()); + // The parser concatenates the StatusCode reason with the description text + assertEquals("Bad Request: if the input is invalid.", throwsMap.get(400)); + assertEquals("Conflict: Conflict occurred.", throwsMap.get(409)); + } + + @Test + public void shouldExtractOperationId() throws Exception { + var source = + """ + package io.jooby.internal.javadoc; + + public class Controller { + /** + * @operationId customOperationName + */ + public void myMethod() {} + } + """; + + var result = JavadocTestHelper.parseCode(source); + var methodNode = result.getMethodNode(); + var javadocNode = getJavadocNode(methodNode); + + var doc = new MethodDoc(null, methodNode, javadocNode); + + assertEquals("customOperationName", doc.getOperationId()); + } + + @Test + public void shouldHandleMethodsWithoutJavadocGracefully() throws Exception { + var source = + """ + package io.jooby.internal.javadoc; + + public class Controller { + public void myMethod() {} + } + """; + + var result = JavadocTestHelper.parseCode(source); + var methodNode = result.getMethodNode(); + + // javadocNode will be null because there is no block comment + var javadocNode = getJavadocNode(methodNode); + + // Our null-safe toJavaDocNode() fix handles this gracefully + var doc = new MethodDoc(null, methodNode, javadocNode); + + assertNull(doc.getOperationId()); + assertNull(doc.getReturnDoc()); + assertTrue(doc.getSecurityRequirements().isEmpty()); + assertTrue(doc.getThrows().isEmpty()); + } +} diff --git a/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/PrintAstTree.java similarity index 94% rename from modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java rename to modules/jooby-javadoc/src/test/java/io/jooby/javadoc/PrintAstTree.java index 4cb088c810..33249dbbb7 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/PrintAstTree.java +++ b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/PrintAstTree.java @@ -3,16 +3,16 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package javadoc; +package io.jooby.javadoc; import java.io.File; import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; -import javadoc.input.ApiDoc; import com.puppycrawl.tools.checkstyle.AstTreeStringPrinter; import com.puppycrawl.tools.checkstyle.api.CheckstyleException; +import io.jooby.javadoc.input.ApiDoc; public class PrintAstTree { public static void main(String[] args) throws CheckstyleException, IOException { diff --git a/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/Address.java b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/Address.java new file mode 100644 index 0000000000..a422ed3450 --- /dev/null +++ b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/Address.java @@ -0,0 +1,57 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.javadoc.input; + +/** Author address. */ +public class Address { + /** Street name. */ + private String street; + + private String city; + + /** State. */ + private String state; + + /** Two digit country code. */ + private String country; + + public String getStreet() { + return street; + } + + public void setStreet(String street) { + this.street = street; + } + + /** + * City name. + * + * @return City name. + */ + public String getCity() { + return city; + } + + public void setCity(String city) { + this.city = city; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public String getCountry() { + return country; + } + + public void setCountry(String country) { + this.country = country; + } +} diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/ApiDoc.java similarity index 91% rename from modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java rename to modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/ApiDoc.java index 101c04da71..683474d3de 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/ApiDoc.java +++ b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/ApiDoc.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package javadoc.input; +package io.jooby.javadoc.input; import java.util.List; @@ -44,10 +44,10 @@ public class ApiDoc { */ @NonNull @GET public String hello( - @QueryParam List> name, + @QueryParam List> name, @QueryParam int age, - @QueryParam List list, - @QueryParam java.lang.String str) { + @QueryParam List list, + @QueryParam String str) { return "hello"; } diff --git a/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/Author.java b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/Author.java new file mode 100644 index 0000000000..ef5639dd63 --- /dev/null +++ b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/Author.java @@ -0,0 +1,55 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.javadoc.input; + +import java.util.Set; + +public class Author { + /* + * Social security number. + */ + String ssn; + + /** Author's name. */ + String name; + + Address address; + + /** Published books. */ + Set books; + + public String getSsn() { + return ssn; + } + + public void setSsn(String ssn) { + this.ssn = ssn; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Address getAddress() { + return address; + } + + public void setAddress(Address address) { + this.address = address; + } + + public Set getBooks() { + return books; + } + + public void setBooks(Set books) { + this.books = books; + } +} diff --git a/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/Book.java b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/Book.java new file mode 100644 index 0000000000..133cf97efe --- /dev/null +++ b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/Book.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.javadoc.input; + +import java.time.LocalDate; +import java.util.Set; + +/** Book model. */ +public class Book { + /** Book ISBN. */ + private String isbn; + + /** Book's title. */ + String title; + + /** Publication date. Format mm-dd-yyyy. */ + LocalDate publicationDate; + + /** Book's content. */ + String text; + + /** Book type. */ + Type type = Type.Fiction; + + Set authors; + + /** + * Book ISBN. Method. + * + * @return Book ISBN. Method. + */ + public String getIsbn() { + return isbn; + } + + public void setIsbn(String isbn) { + this.isbn = isbn; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public LocalDate getPublicationDate() { + return publicationDate; + } + + public void setPublicationDate(LocalDate publicationDate) { + this.publicationDate = publicationDate; + } + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + + public Type getType() { + return type; + } + + public void setType(Type type) { + this.type = type; + } + + public Set getAuthors() { + return authors; + } + + public void setAuthors(Set authors) { + this.authors = authors; + } +} diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/EnumDoc.java b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/EnumDoc.java similarity index 87% rename from modules/jooby-openapi/src/test/java/javadoc/input/EnumDoc.java rename to modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/EnumDoc.java index 06dc4c4385..3174ce4fa0 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/EnumDoc.java +++ b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/EnumDoc.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package javadoc.input; +package io.jooby.javadoc.input; /** Enum summary. Enum desc. */ public enum EnumDoc { diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/InterfaceDoc.java b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/InterfaceDoc.java similarity index 88% rename from modules/jooby-openapi/src/test/java/javadoc/input/InterfaceDoc.java rename to modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/InterfaceDoc.java index 172bfb13de..3cef805340 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/InterfaceDoc.java +++ b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/InterfaceDoc.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package javadoc.input; +package io.jooby.javadoc.input; /** Interface documentation. */ public interface InterfaceDoc { diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/LambdaRefApp.java b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/LambdaRefApp.java similarity index 85% rename from modules/jooby-openapi/src/test/java/javadoc/input/LambdaRefApp.java rename to modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/LambdaRefApp.java index fe77babc81..32a12bc038 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/LambdaRefApp.java +++ b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/LambdaRefApp.java @@ -3,13 +3,12 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package javadoc.input; - -import javadoc.input.sub.SubPackageHandler; +package io.jooby.javadoc.input; import edu.umd.cs.findbugs.annotations.NonNull; import io.jooby.Context; import io.jooby.Jooby; +import io.jooby.javadoc.input.sub.SubPackageHandler; /** * Lambda App. @@ -19,7 +18,7 @@ public class LambdaRefApp extends Jooby { { get("/reference", this::findPetById); - post("/static-reference", javadoc.input.LambdaRefApp::staticFindPetById); + post("/static-reference", LambdaRefApp::staticFindPetById); put("/external-reference", RequestHandler::external); get("/external-subPackage-reference", SubPackageHandler::subPackage); } diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/MultilineComment.java b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/MultilineComment.java similarity index 94% rename from modules/jooby-openapi/src/test/java/javadoc/input/MultilineComment.java rename to modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/MultilineComment.java index f62552a84c..bcdb959664 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/MultilineComment.java +++ b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/MultilineComment.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package javadoc.input; +package io.jooby.javadoc.input; import edu.umd.cs.findbugs.annotations.NonNull; import io.jooby.Context; diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/NoClassDoc.java b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/NoClassDoc.java similarity index 93% rename from modules/jooby-openapi/src/test/java/javadoc/input/NoClassDoc.java rename to modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/NoClassDoc.java index 19ee1eab5d..5b6a5a8071 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/NoClassDoc.java +++ b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/NoClassDoc.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package javadoc.input; +package io.jooby.javadoc.input; import edu.umd.cs.findbugs.annotations.NonNull; import io.jooby.annotation.GET; diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/NoDoc.java b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/NoDoc.java similarity index 92% rename from modules/jooby-openapi/src/test/java/javadoc/input/NoDoc.java rename to modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/NoDoc.java index acc547d244..8ff0eeb8eb 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/NoDoc.java +++ b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/NoDoc.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package javadoc.input; +package io.jooby.javadoc.input; import edu.umd.cs.findbugs.annotations.NonNull; import io.jooby.annotation.GET; diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/QueryBeanDoc.java b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/QueryBeanDoc.java similarity index 97% rename from modules/jooby-openapi/src/test/java/javadoc/input/QueryBeanDoc.java rename to modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/QueryBeanDoc.java index 2d4d3c86d4..d0133a3485 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/QueryBeanDoc.java +++ b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/QueryBeanDoc.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package javadoc.input; +package io.jooby.javadoc.input; import edu.umd.cs.findbugs.annotations.NonNull; import jakarta.validation.constraints.Min; diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/RecordBeanDoc.java b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/RecordBeanDoc.java similarity index 92% rename from modules/jooby-openapi/src/test/java/javadoc/input/RecordBeanDoc.java rename to modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/RecordBeanDoc.java index affb347ff6..845d054bab 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/RecordBeanDoc.java +++ b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/RecordBeanDoc.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package javadoc.input; +package io.jooby.javadoc.input; import jakarta.validation.constraints.NotEmpty; diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/RequestHandler.java b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/RequestHandler.java similarity index 90% rename from modules/jooby-openapi/src/test/java/javadoc/input/RequestHandler.java rename to modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/RequestHandler.java index e3bdd4f4ee..29c1b608d9 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/RequestHandler.java +++ b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/RequestHandler.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package javadoc.input; +package io.jooby.javadoc.input; import io.jooby.Context; diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/ScopeDoc.java b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/ScopeDoc.java similarity index 95% rename from modules/jooby-openapi/src/test/java/javadoc/input/ScopeDoc.java rename to modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/ScopeDoc.java index d79c5ec5cd..e3e9d52131 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/ScopeDoc.java +++ b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/ScopeDoc.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package javadoc.input; +package io.jooby.javadoc.input; // Ignored /** diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/ScriptApp.java b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/ScriptApp.java similarity index 98% rename from modules/jooby-openapi/src/test/java/javadoc/input/ScriptApp.java rename to modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/ScriptApp.java index 16726d15d8..c2dea2ec1b 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/ScriptApp.java +++ b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/ScriptApp.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package javadoc.input; +package io.jooby.javadoc.input; import java.util.function.Predicate; diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/Subclass.java b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/Subclass.java similarity index 87% rename from modules/jooby-openapi/src/test/java/javadoc/input/Subclass.java rename to modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/Subclass.java index f169ba3a47..3e4b9f5681 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/Subclass.java +++ b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/Subclass.java @@ -3,9 +3,9 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package javadoc.input; +package io.jooby.javadoc.input; -import javadoc.input.sub.Base; +import io.jooby.javadoc.input.sub.Base; /** * Subclass summary. diff --git a/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/Type.java b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/Type.java new file mode 100644 index 0000000000..ce844de1d5 --- /dev/null +++ b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/Type.java @@ -0,0 +1,21 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.javadoc.input; + +/** + * Books can be broadly categorized into fiction and non-fiction. With many genres and subgenres + * within each. + */ +public enum Type { + /** + * Fiction books are based on imaginary characters and events, while non-fiction books are based o + * n real people and events. + */ + Fiction, + + /** Non-fiction genres include biography, autobiography, history, self-help, and true crime. */ + NonFiction; +} diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/sub/Base.java b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/sub/Base.java similarity index 92% rename from modules/jooby-openapi/src/test/java/javadoc/input/sub/Base.java rename to modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/sub/Base.java index b878717e46..c78b911b10 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/sub/Base.java +++ b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/sub/Base.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package javadoc.input.sub; +package io.jooby.javadoc.input.sub; /** * Base summary. diff --git a/modules/jooby-openapi/src/test/java/javadoc/input/sub/SubPackageHandler.java b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/sub/SubPackageHandler.java similarity index 90% rename from modules/jooby-openapi/src/test/java/javadoc/input/sub/SubPackageHandler.java rename to modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/sub/SubPackageHandler.java index 28966c8d1e..60e31bf43e 100644 --- a/modules/jooby-openapi/src/test/java/javadoc/input/sub/SubPackageHandler.java +++ b/modules/jooby-javadoc/src/test/java/io/jooby/javadoc/input/sub/SubPackageHandler.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package javadoc.input.sub; +package io.jooby.javadoc.input.sub; import io.jooby.Context; diff --git a/modules/jooby-openapi/pom.xml b/modules/jooby-openapi/pom.xml index 2ff6b1b5ec..5034fa80d3 100644 --- a/modules/jooby-openapi/pom.xml +++ b/modules/jooby-openapi/pom.xml @@ -18,44 +18,38 @@ ${jooby.version} + + io.jooby + jooby-javadoc + ${jooby.version} + + jakarta.ws.rs jakarta.ws.rs-api - jakarta.validation jakarta.validation-api - org.ow2.asm asm - org.ow2.asm asm-util - com.fasterxml.jackson.datatype jackson-datatype-jdk8 - io.swagger.core.v3 swagger-models - - - com.puppycrawl.tools - checkstyle - 13.3.0 - - org.asciidoctor asciidoctorj @@ -206,13 +200,6 @@ default-testCompile none - - - - - - - java-test-compile diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/JavaDocMapper.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/JavaDocMapper.java new file mode 100644 index 0000000000..1484a431fd --- /dev/null +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/JavaDocMapper.java @@ -0,0 +1,212 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.openapi; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import io.jooby.javadoc.DocTag; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.security.*; +import io.swagger.v3.oas.models.servers.Server; +import io.swagger.v3.oas.models.tags.Tag; + +public final class JavaDocMapper { + @SuppressWarnings("unchecked") + public static Map toSecuritySchemes( + Map> data) { + if (data == null || data.isEmpty()) return Map.of(); + + var result = new LinkedHashMap(); + + data.forEach( + (key, map) -> { + var item = new SecurityScheme(); + item.setDescription((String) map.get("description")); + + item.setName((String) map.get("paramName")); + + var inStr = (String) map.get("in"); + if (inStr != null) { + item.setIn(parseEnum(SecurityScheme.In.class, inStr)); + } + + var typeStr = (String) map.get("type"); + if (typeStr != null) { + item.setType(parseEnum(SecurityScheme.Type.class, typeStr)); + } + + item.setBearerFormat((String) map.get("bearerFormat")); + item.setOpenIdConnectUrl((String) map.get("openIdConnectUrl")); + item.setScheme((String) map.get("scheme")); + + var objectFlows = map.get("flows"); + if (objectFlows instanceof Map hashFlows) { + var flows = new OAuthFlows(); + flows.setImplicit(toOAuthFlow((Map) hashFlows.get("implicit"))); + flows.setPassword(toOAuthFlow((Map) hashFlows.get("password"))); + flows.setAuthorizationCode( + toOAuthFlow((Map) hashFlows.get("authorizationCode"))); + flows.setClientCredentials( + toOAuthFlow((Map) hashFlows.get("clientCredentials"))); + item.setFlows(flows); + } + + result.put(key, item); + }); + + return result; + } + + @SuppressWarnings("unchecked") + private static OAuthFlow toOAuthFlow(Map data) { + if (data == null || data.isEmpty()) { + return null; + } + + var flow = new OAuthFlow(); + flow.setAuthorizationUrl((String) data.get("authorizationUrl")); + flow.setTokenUrl((String) data.get("tokenUrl")); + flow.setRefreshUrl((String) data.get("refreshUrl")); + + // OpenAPI requires the scopes object to exist, even if empty + var scopes = new Scopes(); + + // Sometimes parsers map it to 'scope' instead of 'scopes' by mistake + var scopesObj = data.getOrDefault("scopes", data.get("scope")); + + if (scopesObj instanceof Map scopesMap) { + // Format 1: Parallel arrays (name="[write:pets]", description="[modify pets]") + if (scopesMap.containsKey("name") && scopesMap.containsKey("description")) { + var names = parseListString(String.valueOf(scopesMap.get("name"))); + var descs = parseListString(String.valueOf(scopesMap.get("description"))); + + for (int i = 0; i < names.size(); i++) { + var scopeName = names.get(i); + var scopeDesc = i < descs.size() ? descs.get(i) : ""; + scopes.addString(scopeName, scopeDesc); + } + } else { + // Format 2: Standard Key-Value Map + // Safeguard: if value is null, use "" to prevent `"null"` string in YAML + scopesMap.forEach( + (k, v) -> scopes.addString(String.valueOf(k), v == null ? "" : String.valueOf(v))); + } + } else if (scopesObj instanceof Iterable scopesIterable) { + // Format 3: Iterables/Lists (e.g. ["user:read"] or [{"user:read": ""}]) + for (var item : scopesIterable) { + if (item instanceof Map mapItem) { + mapItem.forEach( + (k, v) -> scopes.addString(String.valueOf(k), v == null ? "" : String.valueOf(v))); + } else if (item != null) { + scopes.addString(String.valueOf(item), ""); + } + } + } else if (scopesObj instanceof String scopesStr) { + // Format 5: Raw String array (e.g. "[user:read]") + for (String scopeName : parseListString(scopesStr)) { + scopes.addString(scopeName, ""); + } + } + + flow.setScopes(scopes); + return flow; + } + + /** Safely unwraps bracketed comma-separated strings into a List. */ + private static java.util.List parseListString(String val) { + if (val == null) return java.util.List.of(); + + val = val.trim(); + if (val.startsWith("[") && val.endsWith("]")) { + val = val.substring(1, val.length() - 1); + } + + return java.util.Arrays.stream(val.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + } + + public static List toServers(List> data) { + if (data == null || data.isEmpty()) return List.of(); + + return data.stream() + .map( + map -> { + var server = new Server(); + server.setUrl((String) map.get("url")); + server.setDescription((String) map.get("description")); + return server; + }) + .toList(); + } + + public static Optional toContact(List> data) { + if (data == null || data.isEmpty()) return Optional.empty(); + + var map = data.getFirst(); + var contact = new Contact(); + contact.setName((String) map.get("name")); + contact.setUrl((String) map.get("url")); + contact.setEmail((String) map.get("email")); + return Optional.of(contact); + } + + public static Optional toLicense(List> data) { + if (data == null || data.isEmpty()) return Optional.empty(); + + var map = data.getFirst(); + var license = new License(); + license.setName((String) map.get("name")); + license.setUrl((String) map.get("url")); + return Optional.of(license); + } + + public static List toTags(List data) { + if (data == null || data.isEmpty()) return List.of(); + + return data.stream() + .map( + docTag -> { + var tag = new Tag(); + tag.setName(docTag.name()); + tag.setDescription(docTag.description()); + return tag; + }) + .toList(); + } + + public static List toSecurityRequirements( + List>> data) { + if (data == null || data.isEmpty()) return List.of(); + + return data.stream() + .map( + map -> { + var req = new SecurityRequirement(); + map.forEach(req::addList); + return req; + }) + .toList(); + } + + /** Helper to safely map string values to Swagger Enums, ignoring case. */ + private static > T parseEnum(Class type, String value) { + if (value == null || value.trim().isEmpty()) { + return null; + } + for (T constant : type.getEnumConstants()) { + if (constant.name().equalsIgnoreCase(value)) { + return constant; + } + } + return null; + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/JavaDocSetter.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/JavaDocSetter.java index efb46f81d9..6bb16b9054 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/JavaDocSetter.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/JavaDocSetter.java @@ -10,9 +10,9 @@ import java.util.Optional; import java.util.stream.Collectors; -import io.jooby.internal.openapi.javadoc.JavaDocNode; -import io.jooby.internal.openapi.javadoc.MethodDoc; -import io.jooby.internal.openapi.javadoc.ScriptDoc; +import io.jooby.javadoc.JavaDocNode; +import io.jooby.javadoc.MethodDoc; +import io.jooby.javadoc.ScriptDoc; import io.swagger.v3.oas.models.parameters.Parameter; public class JavaDocSetter { @@ -20,7 +20,7 @@ public class JavaDocSetter { public static void setPath(OperationExt operation, JavaDocNode doc) { operation.setPathDescription(doc.getDescription()); operation.setPathSummary(doc.getSummary()); - doc.getTags().forEach(operation::addTag); + JavaDocMapper.toTags(doc.getTags()).forEach(operation::addTag); if (!doc.getExtensions().isEmpty()) { operation.setPathExtensions(doc.getExtensions()); } @@ -48,8 +48,9 @@ public static void set(OperationExt operation, MethodDoc doc, List param if (!doc.getExtensions().isEmpty()) { operation.setExtensions(doc.getExtensions()); } - doc.getSecurityRequirements().forEach(operation::addSecurityItem); - doc.getTags().forEach(operation::addTag); + JavaDocMapper.toSecurityRequirements(doc.getSecurityRequirements()) + .forEach(operation::addSecurityItem); + JavaDocMapper.toTags(doc.getTags()).forEach(operation::addTag); // Parameters for (var parameterName : parameterNames) { var paramExt = @@ -78,12 +79,12 @@ public static void set(OperationExt operation, MethodDoc doc, List param defaultResponse.setExamples(doc.getReturnExample()); defaultResponse.setDescription(doc.getReturnDoc()); } - for (var throwsDoc : doc.getThrows().values()) { - var response = operation.getResponse(throwsDoc.getCode()); + for (var throwsDoc : doc.getThrows().entrySet()) { + var response = operation.getResponse(Integer.toString(throwsDoc.getKey())); if (response == null) { - response = operation.addResponse(throwsDoc.getCode()); + response = operation.addResponse(Integer.toString(throwsDoc.getKey())); } - response.setDescription(throwsDoc.getDescription()); + response.setDescription(throwsDoc.getValue()); } } } diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java index a699d77b55..423e373607 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/ParserContext.java @@ -54,7 +54,7 @@ import com.fasterxml.jackson.databind.type.SimpleType; import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; import io.jooby.*; -import io.jooby.internal.openapi.javadoc.JavaDocParser; +import io.jooby.javadoc.JavaDocParser; import io.jooby.openapi.DebugOption; import io.swagger.v3.core.util.RefUtils; import io.swagger.v3.oas.models.SpecVersion; diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java deleted file mode 100644 index 7fb1c6136f..0000000000 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ClassDoc.java +++ /dev/null @@ -1,274 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.openapi.javadoc; - -import static io.jooby.internal.openapi.javadoc.JavaDocStream.*; -import static io.jooby.internal.openapi.javadoc.JavaDocSupport.getClassName; - -import java.util.*; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import com.puppycrawl.tools.checkstyle.DetailAstImpl; -import com.puppycrawl.tools.checkstyle.api.DetailAST; -import com.puppycrawl.tools.checkstyle.api.JavadocCommentsTokenTypes; -import com.puppycrawl.tools.checkstyle.api.TokenTypes; -import com.puppycrawl.tools.checkstyle.utils.TokenUtil; -import io.swagger.v3.oas.models.info.Contact; -import io.swagger.v3.oas.models.info.License; -import io.swagger.v3.oas.models.security.SecurityScheme; -import io.swagger.v3.oas.models.servers.Server; - -public class ClassDoc extends JavaDocNode { - private final Map fields = new LinkedHashMap<>(); - private final Map methods = new LinkedHashMap<>(); - private final Map scripts = new LinkedHashMap<>(); - private final List servers; - private final List contact; - private final List license; - private final Map securitySchemes; - - public ClassDoc(JavaDocParser ctx, DetailAST node, DetailAST javaDoc) { - super(ctx, node, javaDoc); - if (isRecord()) { - defaultRecordMembers(); - } else if (isEnum()) { - defaultEnumMembers(); - } - this.servers = JavaDocTag.servers(this.javadoc); - this.contact = JavaDocTag.contacts(this.javadoc); - this.license = JavaDocTag.license(this.javadoc); - this.securitySchemes = JavaDocTag.securitySchemes(this.javadoc); - } - - public Map getSecuritySchemes() { - return securitySchemes; - } - - public List getServers() { - return servers; - } - - public List getContact() { - return contact; - } - - public List getLicense() { - return license; - } - - public String getVersion() { - return tree(javadoc) - .filter(javadocToken(JavadocCommentsTokenTypes.VERSION_BLOCK_TAG)) - .findFirst() - .flatMap( - version -> - tree(version.getParent()) - .filter(javadocToken(JavadocCommentsTokenTypes.DESCRIPTION)) - .findFirst() - .map(it -> getText(children(it).toList(), true))) - .orElse(null); - } - - public String getEnumDescription(String text) { - if (isEnum()) { - var sb = new StringBuilder(); - var summary = Optional.ofNullable(text).orElseGet(this::getSummary); - if (summary != null) { - sb.append(summary); - } - for (Map.Entry e : fields.entrySet()) { - sb.append("\n - ").append(e.getKey()).append(": ").append(e.getValue().getText()); - } - return sb.toString().trim(); - } - return text; - } - - public String getEnumItemDescription(String name) { - if (isEnum()) { - var field = fields.get(name); - if (field != null) { - return field.getText(); - } - } - return null; - } - - private void defaultRecordMembers() { - JavaDocTag.javaDocTag( - javadoc, - tag -> { - var isParam = tree(tag).anyMatch(javadocToken(JavadocCommentsTokenTypes.PARAM_BLOCK_TAG)); - var name = - tree(tag) - .filter(javadocToken(JavadocCommentsTokenTypes.PARAMETER_NAME)) - .findFirst() - .orElse(null); - return isParam && name != null; - }, - (tag, value) -> { - var name = - tree(tag) - .filter(javadocToken(JavadocCommentsTokenTypes.PARAMETER_NAME)) - .findFirst() - .orElse(null); - // name is never null bc previous filter - Objects.requireNonNull(name, "name is null"); - /* Virtual Field */ - var memberDoc = - tree(tag) - .filter(javadocToken(JavadocCommentsTokenTypes.DESCRIPTION)) - .findFirst() - .orElse(EMPTY_NODE); - var field = - new FieldDoc( - context, createVirtualMember(name.getText(), TokenTypes.VARIABLE_DEF), memberDoc); - addField(field); - /* Virtual method */ - var method = - new MethodDoc( - context, - createVirtualMember(name.getText(), TokenTypes.METHOD_DEF), - memberDoc) - .markAsVirtual(); - - addMethod(method); - }); - } - - private void defaultEnumMembers() { - for (var constant : tree(node).filter(tokens(TokenTypes.ENUM_CONSTANT_DEF)).toList()) { - /* Virtual Field */ - var name = - tree(constant) - .filter(tokens(TokenTypes.IDENT)) - .findFirst() - .map(DetailAST::getText) - .orElseThrow(() -> new IllegalStateException("Unnamed constant: " + constant)); - var comment = - tree(constant) - .filter(tokens(TokenTypes.BLOCK_COMMENT_BEGIN)) - .findFirst() - .orElse(JavaDocNode.EMPTY_AST); - var field = - new FieldDoc(context, createVirtualMember(name, TokenTypes.VARIABLE_DEF), comment); - addField(field); - } - } - - private DetailAstImpl createVirtualMember(String name, int tokenType) { - var publicMod = new DetailAstImpl(); - publicMod.initialize( - TokenTypes.LITERAL_PUBLIC, TokenUtil.getTokenName(TokenTypes.LITERAL_PUBLIC)); - var modifiers = new DetailAstImpl(); - modifiers.initialize(TokenTypes.MODIFIERS, TokenUtil.getTokenName(tokenType)); - modifiers.addChild(publicMod); - var memberName = new DetailAstImpl(); - memberName.initialize(TokenTypes.IDENT, name); - var member = new DetailAstImpl(); - member.initialize(tokenType, TokenUtil.getTokenName(tokenType)); - memberName.addChild(modifiers); - member.addChild(memberName); - return member; - } - - public void addMethod(MethodDoc method) { - this.methods.put(toMethodSignature(method), method); - } - - public void addScript(ScriptDoc method) { - this.scripts.put(toScriptSignature(method), method); - } - - public void addField(FieldDoc field) { - this.fields.put(field.getName(), field); - } - - public Optional getField(String name) { - return Optional.ofNullable(fields.get(name)); - } - - public Optional getMethod(String name, List types) { - return Optional.ofNullable(methods.get(toMethodSignature(name, types))); - } - - public Optional getScript(String method, String pattern) { - return Optional.ofNullable(scripts.get(toScriptSignature(method, pattern))); - } - - private String toScriptSignature(ScriptDoc method) { - return toScriptSignature(method.getMethod(), method.getPattern()); - } - - private String toScriptSignature(String method, String pattern) { - return method + "/" + pattern; - } - - private String toMethodSignature(MethodDoc method) { - return toMethodSignature(method.getName(), method.getParameterTypes()); - } - - private String toMethodSignature(String methodName, List types) { - return methodName + types.stream().collect(Collectors.joining(", ", "(", ")")); - } - - public String getSimpleName() { - return JavaDocSupport.getSimpleName(node); - } - - public String getName() { - return getClassName(node); - } - - public String getPackage() { - return JavaDocSupport.getPackageName(node); - } - - public boolean isRecord() { - return tree(node).anyMatch(tokens(TokenTypes.RECORD_DEF)); - } - - public boolean isEnum() { - return tree(node).anyMatch(tokens(TokenTypes.ENUM_DEF)); - } - - private String propertyDoc(String name) { - var getterDoc = - Stream.of(name, getterName(name)) - .map(n -> methods.get(toMethodSignature(n, List.of()))) - .filter(Objects::nonNull) - .findFirst() - .map(MethodDoc::getText) - .orElse(null); - if (getterDoc == null) { - var field = fields.get(name); - return field == null ? null : field.getText(); - } - return getterDoc; - } - - public String getPropertyDoc(String name) { - var doc = propertyDoc(name); - return doc == null ? null : doc.replace(exampleCode(doc), "").trim(); - } - - public Object getPropertyExample(String name) { - return toExamples(exampleCode(propertyDoc(name))); - } - - private String getterName(String name) { - return "get" + Character.toUpperCase(name.charAt(0)) + name.substring(1); - } - - @Override - public String toString() { - return "fields: " - + String.join(", ", fields.keySet()) - + "\nmethods: " - + String.join(", ", methods.keySet()); - } -} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ContentSplitter.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ContentSplitter.java deleted file mode 100644 index 12e7e80b60..0000000000 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ContentSplitter.java +++ /dev/null @@ -1,158 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.openapi.javadoc; - -public class ContentSplitter { - - public record ContentResult(String summary, String description) {} - - public static ContentResult split(String text) { - if (text == null || text.isEmpty()) { - return new ContentResult("", ""); - } - - int len = text.length(); - int splitIndex = -1; - - // State trackers - int parenDepth = 0; // ( ) - int bracketDepth = 0; // [ ] - int braceDepth = 0; // { } - boolean inHtmlDef = false; // < ... > - boolean inCodeBlock = false; //

...
or ... - - for (int i = 0; i < len; i++) { - char c = text.charAt(i); - - // 1. Handle HTML Tags start - if (c == '<') { - // Check for

(Paragraph Split - Exclusive) - if (!inCodeBlock && !inHtmlDef && isTag(text, i, "p")) { - splitIndex = i; - break; - } - // Check for Protected Blocks (

, )
-        if (!inCodeBlock && (isTag(text, i, "pre") || isTag(text, i, "code"))) {
-          inCodeBlock = true;
-        }
-        // Check for end of Protected Blocks
-        if (inCodeBlock && (isCloseTag(text, i, "pre") || isCloseTag(text, i, "code"))) {
-          inCodeBlock = false;
-        }
-        inHtmlDef = true;
-        continue;
-      }
-
-      // 2. Handle HTML Tags end
-      if (c == '>') {
-        inHtmlDef = false;
-        continue;
-      }
-
-      // 3. Handle Nesting & Split
-      if (!inHtmlDef && !inCodeBlock) {
-        if (c == '(') {
-          parenDepth++;
-        } else if (c == ')') {
-          if (parenDepth > 0) parenDepth--;
-        } else if (c == '[') {
-          bracketDepth++;
-        } else if (c == ']') {
-          if (bracketDepth > 0) bracketDepth--;
-        } else if (c == '{') {
-          braceDepth++;
-        } else if (c == '}') {
-          if (braceDepth > 0) braceDepth--;
-        }
-        // 4. Check for Period
-        else if (c == '.') {
-          if (parenDepth == 0 && bracketDepth == 0 && braceDepth == 0) {
-            splitIndex = i + 1;
-            break;
-          }
-        }
-      }
-    }
-
-    String summary;
-    String description;
-
-    if (splitIndex == -1) {
-      summary = text.trim();
-      description = "";
-    } else {
-      summary = text.substring(0, splitIndex).trim();
-      description = text.substring(splitIndex).trim();
-    }
-
-    // Clean up: Strip 

tags without using Regex - return new ContentResult(stripParagraphTags(summary), stripParagraphTags(description)); - } - - /** - * Removes - * - *

and tags (and their attributes) from the text. Keeps content inside the tags. - */ - private static String stripParagraphTags(String text) { - if (text.isEmpty()) return text; - - StringBuilder sb = new StringBuilder(text.length()); - int len = text.length(); - - for (int i = 0; i < len; i++) { - char c = text.charAt(i); - - if (c == '<') { - // Detect or - if (isTag(text, i, "p") || isCloseTag(text, i, "p")) { - // Fast-forward until we find the closing '>' - while (i < len && text.charAt(i) != '>') { - i++; - } - // We are now at '>', loop increment will move past it - continue; - } - } - sb.append(c); - } - return sb.toString().trim(); - } - - // --- Helper Methods --- - - private static boolean isTag(String text, int i, String tagName) { - int len = tagName.length(); - if (i + 1 + len > text.length()) return false; - - // Match tagName (case insensitive) - for (int k = 0; k < len; k++) { - char c = text.charAt(i + 1 + k); - if (Character.toLowerCase(c) != tagName.charAt(k)) return false; - } - - // Check delimiter (must be '>' or whitespace or end of string) - if (i + 1 + len == text.length()) return true; - char delimiter = text.charAt(i + 1 + len); - return delimiter == '>' || Character.isWhitespace(delimiter); - } - - private static boolean isCloseTag(String text, int i, String tagName) { - int len = tagName.length(); - if (i + 2 + len > text.length()) return false; - if (text.charAt(i + 1) != '/') return false; - - // Match tagName (case insensitive) - for (int k = 0; k < len; k++) { - char c = text.charAt(i + 2 + k); - if (Character.toLowerCase(c) != tagName.charAt(k)) return false; - } - - // Check delimiter - char delimiter = text.charAt(i + 2 + len); - return delimiter == '>' || Character.isWhitespace(delimiter); - } -} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/FieldDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/FieldDoc.java deleted file mode 100644 index 6ce38c22ce..0000000000 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/FieldDoc.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.openapi.javadoc; - -import com.puppycrawl.tools.checkstyle.api.DetailAST; -import com.puppycrawl.tools.checkstyle.api.DetailNode; - -public class FieldDoc extends JavaDocNode { - public FieldDoc(JavaDocParser ctx, DetailAST node, DetailAST javadoc) { - super(ctx, node, javadoc); - } - - FieldDoc(JavaDocParser ctx, DetailAST node, DetailNode javadoc) { - super(ctx, node, javadoc); - } - - @Override - public String getText() { - var text = super.getText(); - return text == null ? null : text.replace("

", "").replace("

", "").trim(); - } - - public String getName() { - return JavaDocSupport.getSimpleName(node); - } -} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java deleted file mode 100644 index 410c4e46b4..0000000000 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocNode.java +++ /dev/null @@ -1,257 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.openapi.javadoc; - -import static io.jooby.internal.openapi.javadoc.JavaDocStream.javadocToken; - -import java.util.*; -import java.util.function.Predicate; - -import com.puppycrawl.tools.checkstyle.DetailNodeTreeStringPrinter; -import com.puppycrawl.tools.checkstyle.JavadocDetailNodeParser; -import com.puppycrawl.tools.checkstyle.api.DetailAST; -import com.puppycrawl.tools.checkstyle.api.DetailNode; -import com.puppycrawl.tools.checkstyle.api.JavadocCommentsTokenTypes; -import io.swagger.v3.oas.models.tags.Tag; - -public class JavaDocNode { - private static final Predicate JAVADOC_TAG = - javadocToken(JavadocCommentsTokenTypes.JAVADOC_BLOCK_TAG); - - protected final JavaDocParser context; - protected final DetailAST node; - protected final DetailNode javadoc; - private final Map extensions; - private final List tags; - - public JavaDocNode(JavaDocParser ctx, DetailAST node, DetailAST comment) { - this(ctx, node, toJavaDocNode(comment)); - } - - protected JavaDocNode(JavaDocParser ctx, DetailAST node, DetailNode javadoc) { - this.context = ctx; - this.node = node; - this.javadoc = javadoc; - this.tags = JavaDocTag.tags(javadoc); - this.extensions = JavaDocTag.extensions(javadoc); - } - - static DetailNode toJavaDocNode(DetailAST node) { - return node == EMPTY_AST - ? EMPTY_NODE - : new JavadocDetailNodeParser().parseJavadocComment(node).getTree(); - } - - public DetailAST getNode() { - return node; - } - - public Map getExtensions() { - return extensions; - } - - public String getSummary() { - var summary = ContentSplitter.split(getText()).summary(); - return summary.isEmpty() ? null : summary; - } - - public List getTags() { - return tags; - } - - public String getDescription() { - var description = ContentSplitter.split(getText()).description(); - return description.isEmpty() ? null : description; - } - - public String getText() { - return getText(JavaDocStream.forward(javadoc, JAVADOC_TAG).toList(), false); - } - - protected String exampleCode(String text) { - if (text == null) { - return ""; - } - var start = text.indexOf("`"); - if (start == -1) { - return ""; - } - var end = text.indexOf("`", start + 1); - if (end == -1) { - return ""; - } - return text.substring(start, end + 1); - } - - protected Object toExamples(String text) { - var codeExample = exampleCode(text); - if (codeExample.isEmpty()) { - return null; - } - var clean = codeExample.substring(1, codeExample.length() - 1); - var result = JavaDocObjectParser.parseJson(clean); - if (result.equals(codeExample)) { - // Like a primitive/basic example - return List.of(result); - } - return result; - } - - protected static String getText(List nodes, boolean stripLeading) { - var builder = new StringBuilder(); - var visited = new HashSet(); - for (var node : nodes) { - if (visited.add(node)) { - if (node.getType() == JavadocCommentsTokenTypes.TEXT) { - var text = node.getText(); - if (stripLeading && Character.isWhitespace(text.charAt(0))) { - builder.append(' ').append(text.stripLeading()); - } else { - builder.append(text); - } - } else if (node.getType() == JavadocCommentsTokenTypes.NEWLINE) { - var next = node.getNextSibling(); - if (next != null && next.getType() != JavadocCommentsTokenTypes.LEADING_ASTERISK) { - builder.append(next.getText()); - visited.add(next); - } - } else if (node.getType() == JavadocCommentsTokenTypes.TAG_NAME) { - //

? - if (node.getText().equals("p")) { - // keep so we can split summary from description - builder.append("

"); - } - } - } - } - return builder.isEmpty() ? null : builder.toString().trim().replaceAll("\\s+", " "); - } - - protected static String toString(DetailNode node) { - return DetailNodeTreeStringPrinter.printTree(node, "", ""); - } - - public static final DetailNode EMPTY_NODE = - new DetailNode() { - @Override - public int getType() { - return JavadocCommentsTokenTypes.JAVADOC_CONTENT; - } - - @Override - public String getText() { - return ""; - } - - @Override - public int getLineNumber() { - return 0; - } - - @Override - public int getColumnNumber() { - return 0; - } - - @Override - public DetailNode getParent() { - return null; - } - - @Override - public DetailNode getNextSibling() { - return null; - } - - @Override - public DetailNode getFirstChild() { - return null; - } - - @Override - public DetailNode getPreviousSibling() { - return null; - } - }; - - public static final DetailAST EMPTY_AST = - new DetailAST() { - @Override - public int getChildCount() { - return 0; - } - - @Override - public int getChildCount(int type) { - return 0; - } - - @Override - public DetailAST getParent() { - return null; - } - - @Override - public String getText() { - return ""; - } - - @Override - public int getType() { - return 0; - } - - @Override - public int getLineNo() { - return 0; - } - - @Override - public int getColumnNo() { - return 0; - } - - @Override - public DetailAST getLastChild() { - return null; - } - - @Override - public boolean branchContains(int type) { - return false; - } - - @Override - public DetailAST getPreviousSibling() { - return null; - } - - @Override - public DetailAST findFirstToken(int type) { - return null; - } - - @Override - public DetailAST getNextSibling() { - return null; - } - - @Override - public DetailAST getFirstChild() { - return null; - } - - @Override - public int getNumberOfChildren() { - return 0; - } - - @Override - public boolean hasChildren() { - return false; - } - }; -} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocObjectParser.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocObjectParser.java deleted file mode 100644 index b6535eeb7e..0000000000 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocObjectParser.java +++ /dev/null @@ -1,361 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.openapi.javadoc; - -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.regex.Pattern; - -/** - * A utility class to parse a list of strings into a nested map structure. It supports multiple data - * rows, dot-separated path expressions, repeated paths, and values explicitly wrapped in square - * brackets '[]'. The structure can be augmented by subsequent rows. - */ -public class JavaDocObjectParser { - - /** - * A parser for a non-standard JSON-like syntax where string keys and values are not enclosed in - * quotation marks. - * - *

This parser supports: - Key-value pairs (e.g., key:value) - Nested objects (text wrapped - * with {}) - Arrays (text wrapped with []) - All other non-object, non-array values are parsed as - * Strings. - * - *

Note: This is a simplified parser. For standard JSON. - */ - public static class UnquotedJsonParser { - - private String text; - private int index = 0; - private static final Pattern WS = Pattern.compile("\\s"); - - /** - * Parses the given unquoted JSON-like string into a Map. - * - * @param input The string to parse. - * @return A Map representing the parsed structure. - * @throws IllegalArgumentException if the input format is invalid. - */ - public Object parse(String input) { - if (input == null || input.trim().isEmpty()) { - throw new IllegalArgumentException("Input string cannot be null or empty."); - } - this.text = input.trim(); - this.index = 0; - if (text.startsWith("{") && text.endsWith("}")) { - return parseObject(); - } else if (text.startsWith("[") && text.endsWith("]")) { - return parseArray(); - } - // generate a literal only for value without spaces - return WS.matcher(input).find() ? input : parseLiteral(); - } - - /** - * Parses an object structure, starting from the current index. An object is expected to be - * enclosed in '{' and '}'. - */ - private Map parseObject() { - Map map = new LinkedHashMap<>(); - - expectChar('{'); - skipWhitespace(); - - while (peek() != '}') { - String key = parseKey(); - skipWhitespace(); - expectChar(':'); - skipWhitespace(); - Object value = parseValue(); - map.put(key, value); - skipWhitespace(); - - if (peek() == ',') { - consumeChar(); - skipWhitespace(); - } else if (peek() != '}') { - throw new IllegalArgumentException("Expected ',' or '}' after value at index " + index); - } - } - - expectChar('}'); - return map; - } - - /** - * Parses an array structure, starting from the current index. An array is expected to be - * enclosed in '[' and ']'. - */ - private List parseArray() { - List list = new ArrayList<>(); - - expectChar('['); - skipWhitespace(); - - while (peek() != ']') { - list.add(parseValue()); - skipWhitespace(); - - if (peek() == ',') { - consumeChar(); - skipWhitespace(); - } else if (peek() != ']') { - throw new IllegalArgumentException( - "Expected ',' or ']' after value in array at index " + index); - } - } - - expectChar(']'); - return list; - } - - /** - * Parses a key from the input string. A key is a sequence of characters up to the colon ':'. - */ - private String parseKey() { - int start = index; - while (index < text.length() && text.charAt(index) != ':') { - index++; - } - if (index == start) { - throw new IllegalArgumentException("Found empty key at index " + start); - } - return text.substring(start, index).trim(); - } - - /** - * Determines the type of the value at the current index and parses it. It can be an object, an - * array, or a string literal. - */ - private Object parseValue() { - skipWhitespace(); - char currentChar = peek(); - - if (currentChar == '{') { - return parseObject(); - } else if (currentChar == '[') { - return parseArray(); - } else { - return parseLiteral(); - } - } - - /** - * Parses a literal value as a string. The literal ends at the next comma ',', closing brace - * '}', or closing bracket ']'. - */ - private Object parseLiteral() { - int start = index; - while (index < text.length() - && text.charAt(index) != ',' - && text.charAt(index) != '}' - && text.charAt(index) != ']') { - index++; - } - var literal = text.substring(start, index).trim(); - try { - return Long.parseLong(literal); - } catch (NumberFormatException ignored) { - try { - return Double.parseDouble(literal); - } catch (NumberFormatException ignored2) { - return "true".equals(literal) - ? Boolean.TRUE - : "false".equals(literal) ? Boolean.FALSE : literal; - } - } - } - - // --- Utility Methods --- - - private char peek() { - if (index >= text.length()) { - throw new IllegalArgumentException("Unexpected end of input."); - } - return text.charAt(index); - } - - private void consumeChar() { - index++; - } - - private void expectChar(char expected) { - skipWhitespace(); - if (peek() != expected) { - throw new IllegalArgumentException( - "Expected '" + expected + "' but found '" + peek() + "' at index " + index); - } - consumeChar(); - } - - private void skipWhitespace() { - while (index < text.length() && Character.isWhitespace(text.charAt(index))) { - index++; - } - } - } - - /** - * Parses a list containing one or more data rows into a list of maps. - A new row is started each - * time the first key of the input list is encountered. - Subsequent rows can add new keys - * (augment the structure) but cannot change the data type of an existing path (e.g., from an - * object to a string). - Repeated paths within a single row will generate a list of values. - A - * value wrapped in '[]' (e.g., "[value]") will be parsed as a list with a single element. - * - * @param inputList The list of strings to parse. Must not be null. - * @return A List of Maps, where each map represents a data row. - * @throws IllegalArgumentException if a row tries to change an existing data structure. - */ - public static List> parse(List inputList) { - try { - if (inputList.isEmpty()) { - return List.of(); - } - - // --- Step 1: Split the flat input list into a list of rows --- - List> allRowsPairs = new ArrayList<>(); - String rowDelimiterKey = inputList.get(0); - List currentRowPairs = new ArrayList<>(); - - for (int i = 0; i < inputList.size(); i += 2) { - String key = inputList.get(i); - if (i + 1 >= inputList.size()) { - break; // Ignore last key if it has no value - } - String value = inputList.get(i + 1); - - if (key.equals(rowDelimiterKey) && !currentRowPairs.isEmpty()) { - allRowsPairs.add(new ArrayList<>(currentRowPairs)); - currentRowPairs.clear(); - } - currentRowPairs.add(key); - currentRowPairs.add(value); - } - if (!currentRowPairs.isEmpty()) { - allRowsPairs.add(new ArrayList<>(currentRowPairs)); - } - - // --- Step 2: Process each row independently and add its map to the result list --- - List> resultList = new ArrayList<>(); - for (List rowPairs : allRowsPairs) { - resultList.add(buildMapFromPairs(rowPairs)); - } - - return resultList; - } catch (Exception ignored) { - // just returns empty, we won't fail the entire process. - return List.of(); - } - } - - /** - * Builds a single nested map from a list of key-value pairs representing one row. This helper - * contains the core parsing logic for a single data row. - * - * @param pairs A list of strings for a single data row. - * @return A single, potentially nested, map. - */ - private static Map buildMapFromPairs(List pairs) { - Map resultMap = new LinkedHashMap<>(); - - // Step 1: Group all values by their full path. - Map> pathValues = new LinkedHashMap<>(); - for (int i = 0; i < pairs.size(); i += 2) { - pathValues.computeIfAbsent(pairs.get(i), k -> new ArrayList<>()).add(pairs.get(i + 1)); - } - - // Step 2: Group paths by their first segment (e.g., "parents" from "parents.name"). - Map> groupedByFirstSegment = new LinkedHashMap<>(); - for (String path : pathValues.keySet()) { - String firstSegment = path.split("\\.")[0]; - groupedByFirstSegment.computeIfAbsent(firstSegment, k -> new ArrayList<>()).add(path); - } - - // Step 3: Build the map by processing each group. - for (Map.Entry> entry : groupedByFirstSegment.entrySet()) { - String groupKey = entry.getKey(); - List pathsInGroup = entry.getValue(); - - boolean isList = pathsInGroup.stream().anyMatch(p -> pathValues.get(p).size() > 1); - boolean isSimpleList = pathsInGroup.size() == 1 && pathsInGroup.get(0).equals(groupKey); - - if (isList && isSimpleList) { - // Case 1: A list of simple values (e.g., "tags": ["a", "b"]). - List values = pathValues.get(groupKey); - resultMap.put(groupKey, values); - } else if (isList) { - // Case 2: A list of nested objects (e.g., "parents": [{...}, {...}]). - int rowCount = pathValues.get(pathsInGroup.get(0)).size(); - List> listOfMaps = new ArrayList<>(); - for (int i = 0; i < rowCount; i++) { - listOfMaps.add(new LinkedHashMap<>()); - } - - for (String path : pathsInGroup) { - List values = pathValues.get(path); - if (values.size() != rowCount) { - throw new IllegalArgumentException( - "Mismatched value count for properties of '" + groupKey + "'."); - } - String innerPath = path.substring(groupKey.length() + 1); - for (int i = 0; i < rowCount; i++) { - setValue(listOfMaps.get(i), innerPath, values.get(i)); - } - } - resultMap.put(groupKey, listOfMaps); - } else { - // Case 3: Simple key-value pairs, possibly nested (e.g., "address.country"). - for (String path : pathsInGroup) { - String value = pathValues.get(path).get(0); - setValue(resultMap, path, parseJson(value)); - } - } - } - return resultMap; - } - - public static Object parseJson(String value) { - try { - return new UnquotedJsonParser().parse(value); - } catch (Exception ignored) { - // just returns the input value, we won't fail the entire process - return value; - } - } - - /** - * A helper method to place a value into a nested map structure based on a dot-separated path. It - * creates nested maps as needed and throws an exception on path conflicts. - * - * @param map The root map to modify. - * @param path The dot-separated path (e.g., "address.country"). - * @param value The value to set at the specified path. - */ - @SuppressWarnings("unchecked") - private static void setValue(Map map, String path, Object value) { - String[] parts = path.split("\\."); - Map currentMap = map; - - for (int i = 0; i < parts.length - 1; i++) { - String part = parts[i]; - Object node = currentMap.computeIfAbsent(part, k -> new LinkedHashMap()); - - if (!(node instanceof Map)) { - throw new IllegalArgumentException( - "Path conflict: '" + part + "' contains a value and cannot be treated as a map."); - } - currentMap = (Map) node; - } - - String finalKey = parts[parts.length - 1]; - if (!currentMap.containsKey(finalKey)) { - // Don't override - currentMap.put(finalKey, value); - } - } -} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocStream.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocStream.java deleted file mode 100644 index a5b1199fd0..0000000000 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocStream.java +++ /dev/null @@ -1,248 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.openapi.javadoc; - -import java.util.*; -import java.util.function.Function; -import java.util.function.Predicate; -import java.util.stream.Stream; -import java.util.stream.StreamSupport; - -import com.puppycrawl.tools.checkstyle.api.DetailAST; -import com.puppycrawl.tools.checkstyle.api.DetailNode; - -public final class JavaDocStream { - - public static Predicate tokens(Integer... types) { - return tokens(Set.of(types)); - } - - private static Predicate tokens(Set types) { - return it -> types.contains(it.getType()); - } - - public static Predicate javadocToken(Integer... types) { - return javadocToken(Set.of(types)); - } - - private static Predicate javadocToken(Set types) { - return it -> types.contains(it.getType()); - } - - /** - * Traverse the tree from current node to parent (backward). - * - * @param node Starting point - * @return Stream. - */ - public static Stream backward(DetailAST node) { - return backward(ASTNode.ast(node)); - } - - /** - * Traverse the tree from the current node to children and sibling (forward). - * - * @param node Starting point - * @return Stream. - */ - public static Stream forward(DetailAST node) { - return forward(ASTNode.ast(node)); - } - - /** - * Traverse the tree from the current node to children and sibling (forward) but keeping the scope - * to the given node (root). - * - * @param node Root node. - * @return Stream. - */ - public static Stream tree(DetailAST node) { - return tree(ASTNode.ast(node)); - } - - public static Stream tree(DetailNode node) { - return tree(ASTNode.javadoc(node)); - } - - public static Stream children(DetailAST node) { - return stream(childrenIterator(ASTNode.ast(node))); - } - - public static Stream children(DetailNode node) { - return stream(childrenIterator(ASTNode.javadoc(node))); - } - - public static Stream backward(DetailNode node) { - return backward(ASTNode.javadoc(node)); - } - - public static Stream forward(DetailNode node, Predicate stopOn) { - var nodes = forward(ASTNode.javadoc(node)).toList(); - var result = new ArrayList(); - for (var it : nodes) { - if (stopOn.test(it)) { - break; - } - result.add(it); - } - return result.stream(); - } - - private static Stream backward(ASTNode node) { - return stream(backwardIterator(node)); - } - - private static Stream forward(ASTNode node) { - return stream(forwardIterator(node)); - } - - private static Stream tree(ASTNode node) { - return stream(treeIterator(node)); - } - - private static Stream stream(Iterator iterator) { - return StreamSupport.stream( - Spliterators.spliteratorUnknownSize(iterator, Spliterator.ORDERED), false); - } - - private static Iterator treeIterator(ASTNode node) { - return forwardIterator(node, false); - } - - private static Iterator forwardIterator(ASTNode node) { - return forwardIterator(node, true); - } - - private static Iterator childrenIterator(ASTNode node) { - return new Iterator<>() { - Function, ASTNode> direction = null; - ASTNode it = node; - - @Override - public boolean hasNext() { - if (direction == null) { - direction = ASTNode::getFirstChild; - } else { - direction = ASTNode::getNextSibling; - } - return direction.apply(it) != null; - } - - @Override - public T next() { - it = direction.apply(it); - return it.getNode(); - } - }; - } - - private static Iterator backwardIterator(ASTNode node) { - return new Iterator<>() { - ASTNode it = node; - - @Override - public boolean hasNext() { - return it.getNode() != null && it.getParent() != null; - } - - @Override - public T next() { - it = it.getParent(); - return it.getNode(); - } - }; - } - - private static Iterator forwardIterator(ASTNode node, boolean full) { - return new Iterator<>() { - ASTNode it = node; - final Stack> stack = new Stack<>(); - - @Override - public boolean hasNext() { - return it != null && it.getNode() != null; - } - - @Override - public T next() { - var sibling = it.getNextSibling(); - if (sibling != null) { - if (full || it != node) { - stack.push(sibling); - } - } - var current = it; - var child = it.getFirstChild(); - if (child == null) { - if (!stack.isEmpty()) { - it = stack.pop(); - } else { - it = null; - } - } else { - it = child; - } - return current.getNode(); - } - }; - } - - private interface ASTNode { - ASTNode getFirstChild(); - - ASTNode getNextSibling(); - - ASTNode getParent(); - - Node getNode(); - - static ASTNode ast(DetailAST node) { - return ast(node, DetailAST::getParent, DetailAST::getFirstChild, DetailAST::getNextSibling); - } - - static ASTNode javadoc(DetailNode node) { - return ast( - node, DetailNode::getParent, DetailNode::getFirstChild, DetailNode::getNextSibling); - } - - static ASTNode ast( - N node, Function parent, Function child, Function sibling) { - return new ASTNode<>() { - - private ASTNode apply(Function fn) { - if (node == null) { - return null; - } - var result = fn.apply(node); - if (result == null) { - return null; - } - return ast(result, parent, child, sibling); - } - - @Override - public ASTNode getParent() { - return apply(parent); - } - - @Override - public ASTNode getFirstChild() { - return apply(child); - } - - @Override - public N getNode() { - return node; - } - - @Override - public ASTNode getNextSibling() { - return apply(sibling); - } - }; - } - } -} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocSupport.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocSupport.java deleted file mode 100644 index 59a5c536ce..0000000000 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocSupport.java +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.openapi.javadoc; - -import static io.jooby.internal.openapi.javadoc.JavaDocStream.*; -import static io.jooby.internal.openapi.javadoc.JavaDocStream.tokens; -import static io.jooby.internal.openapi.javadoc.JavaDocStream.tree; - -import java.util.List; -import java.util.function.Predicate; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import com.puppycrawl.tools.checkstyle.api.DetailAST; -import com.puppycrawl.tools.checkstyle.api.TokenTypes; -import com.puppycrawl.tools.checkstyle.utils.TokenUtil; - -public class JavaDocSupport { - - public static final Predicate TYPES = - tokens( - TokenTypes.CLASS_DEF, - TokenTypes.INTERFACE_DEF, - TokenTypes.ENUM_DEF, - TokenTypes.RECORD_DEF); - - public static final Predicate EXTENDED_TYPES = - TYPES.or(tokens(TokenTypes.VARIABLE_DEF, TokenTypes.PARAMETER_DEF)); - - /** - * Name from class, method, field, parameter. - * - * @param node Node. - * @return Name. - */ - public static String getSimpleName(DetailAST node) { - checkTypeDef(EXTENDED_TYPES, node); - return node.findFirstToken(TokenTypes.IDENT).getText(); - } - - public static String getClassName(DetailAST node) { - checkTypeDef(TYPES, node); - var classScope = - Stream.concat(Stream.of(node), backward(node).filter(TYPES)) - .map(JavaDocSupport::getSimpleName) - .toList(); - - return Stream.concat(Stream.of(getPackageName(node)), classScope.stream()) - .collect(Collectors.joining(".")); - } - - public static DetailAST getCompilationUnit(DetailAST node) { - return backward(node) - .filter(tokens(TokenTypes.COMPILATION_UNIT)) - .findFirst() - .orElseThrow( - () -> - new IllegalArgumentException( - "Compilation unit missing: " + TokenUtil.getTokenName(node.getType()))); - } - - public static String getPackageName(DetailAST node) { - return String.join( - ".", - tree(getCompilationUnit(node)) - .filter(tokens(TokenTypes.PACKAGE_DEF)) - .findFirst() - .map(it -> tree(it).filter(tokens(TokenTypes.IDENT)).map(DetailAST::getText).toList()) - .orElse(List.of())); - } - - public static String getQualifiedName(DetailAST node) { - return tree(node.getFirstChild()) - .filter(tokens(TokenTypes.DOT).negate()) - .map(DetailAST::getText) - .collect(Collectors.joining(".")); - } - - public static String toQualifiedName(DetailAST classDef, String typeName) { - return switch (typeName) { - case "char", "boolean", "int", "short", "long", "float", "double" -> typeName; - case "Character" -> Character.class.getName(); - case "Boolean" -> Boolean.class.getName(); - case "Integer" -> Integer.class.getName(); - case "Short" -> Short.class.getName(); - case "Long" -> Long.class.getName(); - case "Float" -> Float.class.getName(); - case "Double" -> Double.class.getName(); - case "String" -> String.class.getName(); - default -> { - checkTypeDef(TYPES, classDef); - if (!typeName.contains(".")) { - if (!getSimpleName(classDef).equals(typeName)) { - var cu = getCompilationUnit(classDef); - yield children(cu) - .filter(tokens(TokenTypes.IMPORT)) - .map( - it -> - tree(it.getFirstChild()) - .filter(tokens(TokenTypes.DOT).negate()) - .map(DetailAST::getText) - .collect(Collectors.joining("."))) - .filter(qualifiedName -> qualifiedName.endsWith("." + typeName)) - .findFirst() - .orElseGet(() -> String.join(".", getPackageName(classDef), typeName)); - } - } - // Already qualified. - yield typeName; - } - }; - } - - private static void checkTypeDef(Predicate predicate, DetailAST node) { - if (!predicate.test(node)) { - throw new IllegalArgumentException( - "Must be a type definition, found: " + TokenUtil.getTokenName(node.getType())); - } - } -} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocTag.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocTag.java deleted file mode 100644 index e74804535c..0000000000 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/JavaDocTag.java +++ /dev/null @@ -1,372 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.openapi.javadoc; - -import static io.jooby.internal.openapi.javadoc.JavaDocNode.getText; -import static io.jooby.internal.openapi.javadoc.JavaDocStream.*; -import static io.jooby.internal.openapi.javadoc.JavaDocStream.children; -import static java.util.Optional.ofNullable; - -import java.util.*; -import java.util.function.Consumer; -import java.util.function.Predicate; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import com.puppycrawl.tools.checkstyle.api.DetailNode; -import com.puppycrawl.tools.checkstyle.api.JavadocCommentsTokenTypes; -import io.jooby.SneakyThrows.Consumer2; -import io.jooby.SneakyThrows.Consumer3; -import io.jooby.StatusCode; -import io.jooby.internal.openapi.ResponseExt; -import io.swagger.v3.oas.models.info.Contact; -import io.swagger.v3.oas.models.info.License; -import io.swagger.v3.oas.models.security.*; -import io.swagger.v3.oas.models.servers.Server; -import io.swagger.v3.oas.models.tags.Tag; - -public class JavaDocTag { - private static final Predicate TAG_SHORT = it -> it.getText().equals("tag"); - private static final Predicate TAG = it -> it.getText().startsWith("tag."); - private static final Predicate SERVER = it -> it.getText().startsWith("server."); - private static final Predicate SECURITY = - it -> it.getText().equals("security") || it.getText().equals("securityRequirement"); - private static final Predicate SECURITY_SCHEME = - it -> it.getText().startsWith("securityScheme."); - private static final Predicate CONTACT = it -> it.getText().startsWith("contact."); - private static final Predicate LICENSE = it -> it.getText().startsWith("license."); - private static final Predicate OPERATION_ID = - it -> it.getText().equals("operationId"); - private static final Predicate EXTENSION = it -> it.getText().startsWith("x-"); - private static final Predicate THROWS = - it -> tree(it).anyMatch(javadocToken(JavadocCommentsTokenTypes.THROWS_BLOCK_TAG)); - private static final Predicate PARAM = - it -> tree(it).anyMatch(javadocToken(JavadocCommentsTokenTypes.PARAM_BLOCK_TAG)); - - public static Map getParametersDoc(DetailNode node) { - var parameters = new LinkedHashMap(); - javaDocTag( - node, - PARAM, - (tag, value) -> { - tree(tag) - .filter(javadocToken(JavadocCommentsTokenTypes.PARAMETER_NAME)) - .findFirst() - .map(DetailNode::getText) - .ifPresent( - name -> - tree(tag) - .filter(javadocToken(JavadocCommentsTokenTypes.DESCRIPTION)) - .findFirst() - .map(description -> JavaDocNode.getText(tree(description).toList(), true)) - .ifPresent(text -> parameters.put(name, text))); - }); - return parameters; - } - - public static String getReturnDoc(DetailNode node) { - var text = new StringBuilder(); - javaDocTag( - node, - javadocToken(JavadocCommentsTokenTypes.RETURN_BLOCK_TAG), - (tag, value) -> { - tree(tag.getParent()) - .filter(javadocToken(JavadocCommentsTokenTypes.DESCRIPTION)) - .findFirst() - .map(description -> JavaDocNode.getText(tree(description).toList(), true)) - .ifPresent(text::append); - }); - var result = text.toString().trim(); - return result.isEmpty() ? null : result; - } - - public static List securityRequirement(DetailNode node) { - return parse(node, SECURITY, null).stream() - .map( - hash -> - hash.containsKey("security") - ? hash.get("security") - : hash.get("securityRequirement")) - .map(Object::toString) - .map(String::trim) - .map( - value -> { - // look first space - var indexOf = value.indexOf(' '); - String key; - String scopes; - if (indexOf > 0) { - key = value.substring(0, indexOf).trim(); - scopes = value.substring(indexOf + 1).trim(); - } else { - key = value; - scopes = ""; - } - if (scopes.startsWith("[") && scopes.endsWith("]")) { - scopes = scopes.substring(1, scopes.length() - 1); - } - var scopeList = Stream.of(scopes.split(",")).map(String::trim).toList(); - var security = new SecurityRequirement(); - security.addList(key, scopeList); - return security; - }) - .toList(); - } - - public static Map securitySchemes(DetailNode node) { - var result = new LinkedHashMap(); - parse(node, SECURITY_SCHEME, "securityScheme") - .forEach( - hash -> { - var item = new SecurityScheme(); - item.setDescription((String) hash.get("description")); - var name = (String) hash.get("name"); - item.setName((String) hash.get("paramName")); - ofNullable((String) hash.get("in")) - .map(String::toUpperCase) - .map(SecurityScheme.In::valueOf) - .ifPresent(item::setIn); - ofNullable((String) hash.get("type")) - .map(String::toUpperCase) - .map(SecurityScheme.Type::valueOf) - .ifPresent(item::setType); - item.setBearerFormat((String) hash.get("bearerFormat")); - item.setOpenIdConnectUrl((String) hash.get("openIdConnectUrl")); - item.setScheme((String) hash.get("scheme")); - var objectFlows = hash.get("flows"); - if (objectFlows instanceof Map hashFlows) { - OAuthFlows flows = new OAuthFlows(); - toOauthFlow("implicit", hashFlows, flows::setImplicit); - toOauthFlow("password", hashFlows, flows::setPassword); - toOauthFlow("authorizationCode", hashFlows, flows::setAuthorizationCode); - toOauthFlow("clientCredentials", hashFlows, flows::setClientCredentials); - item.setFlows(flows); - } - result.put(name, item); - }); - return result; - } - - private static void toOauthFlow(String path, Map flows, Consumer consumer) { - var flowHash = flows.get(path); - if (flowHash instanceof Map hash) { - var oauthFlow = new OAuthFlow(); - oauthFlow.setAuthorizationUrl((String) hash.get("authorizationUrl")); - oauthFlow.setTokenUrl((String) hash.get("tokenUrl")); - oauthFlow.setRefreshUrl((String) hash.get("refreshUrl")); - var scopesObject = hash.get("scopes"); - List scopeNames; - List scopeDescriptions; - if (scopesObject instanceof Map scopesHash) { - scopeNames = ensureList(scopesHash.get("name")); - scopeDescriptions = ensureList(scopesHash.get("description")); - } else { - scopeNames = ensureList(scopesObject); - scopeDescriptions = List.of(); - } - if (!scopeNames.isEmpty()) { - Scopes scopes = new Scopes(); - for (int i = 0; i < scopeNames.size(); i++) { - var description = i < scopeDescriptions.size() ? scopeDescriptions.get(i) : ""; - scopes.addString(scopeNames.get(i), description); - } - oauthFlow.setScopes(scopes); - } - consumer.accept(oauthFlow); - } - } - - @SuppressWarnings({"unchecked", "rawtypes"}) - private static List ensureList(Object value) { - if (value == null) return List.of(); - if (value instanceof List list) { - return list.stream().map(Objects::toString).toList(); - } - return List.of(value.toString()); - } - - public static List servers(DetailNode node) { - return parse(node, SERVER, "server").stream() - .map( - hash -> { - var server = new Server(); - server.setDescription((String) hash.get("description")); - server.setUrl((String) hash.get("url")); - return server; - }) - .toList(); - } - - public static List contacts(DetailNode node) { - return parse(node, CONTACT, "contact").stream() - .map( - hash -> { - var item = new Contact(); - item.setName((String) hash.get("name")); - item.setUrl((String) hash.get("url")); - item.setEmail((String) hash.get("email")); - return item; - }) - .toList(); - } - - public static List license(DetailNode node) { - return parse(node, LICENSE, "license").stream() - .map( - hash -> { - var item = new License(); - item.setName((String) hash.get("name")); - item.setIdentifier((String) hash.get("identifier")); - item.setUrl((String) hash.get("url")); - return item; - }) - .toList(); - } - - private static List> parse( - DetailNode node, Predicate filter, String path) { - var values = new ArrayList(); - javaDocTag( - node, - filter, - (tag, value) -> { - values.add(tag.getText()); // .substring(1)); - values.add(value); - }); - return JavaDocObjectParser.parse(values).stream() - .map(hash -> path == null ? hash : (Map) hash.get(path)) - .toList(); - } - - public static Map throwList(DetailNode node) { - var result = new LinkedHashMap(); - javaDocTag( - node, - THROWS, - (tag, text) -> { - var statusCode = - tree(tag) - .filter(javadocToken(JavadocCommentsTokenTypes.DESCRIPTION)) - .findFirst() - .flatMap( - it -> - tree(it) - .filter(tagName -> tagName.getText().equals("code")) - .map(DetailNode::getParent) - .map(DetailNode::getParent) - .flatMap( - start -> - children(start) - .filter( - javadocToken(JavadocCommentsTokenTypes.HTML_CONTENT))) - .flatMap( - htmlTag -> - children(htmlTag) - .filter(javadocToken(JavadocCommentsTokenTypes.TEXT)) - .findFirst() - .stream()) - .map(DetailNode::getText) - .map( - value -> { - try { - return Integer.parseInt(value); - } catch (NumberFormatException e) { - return null; - } - }) - .filter(Objects::nonNull) - .filter(code -> code >= 400 && code <= 600) - .map(StatusCode::valueOf) - .findFirst()) - .orElse(null); - if (statusCode != null) { - if (text == null || text.trim().isEmpty()) { - text = statusCode.reason(); - } else { - text = statusCode.reason() + ": " + text; - } - var throwsDoc = new ResponseExt(Integer.toString(statusCode.value())); - throwsDoc.setDescription(text); - result.putIfAbsent(statusCode, throwsDoc); - } - }); - return result; - } - - public static Map extensions(DetailNode node) { - return parse(node, EXTENSION, null).stream().findFirst().orElse(Map.of()); - } - - public static List tags(DetailNode node) { - var tags = - parse(node, TAG_SHORT, null).stream() - .map(hash -> hash.get("tag").toString().trim()) - .map( - value -> { - // look first space - var indexOf = value.indexOf('.'); - String name; - String description; - if (indexOf > 0) { - name = value.substring(0, indexOf).trim(); - description = value.substring(indexOf + 1).trim(); - } else { - name = value; - description = null; - } - return createTag(name, description); - }) - .collect(Collectors.toList()); - parse(node, TAG, "tag").stream() - .map(hash -> createTag((String) hash.get("name"), (String) hash.get("description"))) - .forEach(tags::add); - return tags; - } - - private static Tag createTag(String tagName, String tagDescription) { - Tag tag = new Tag(); - tag.setName(tagName); - tag.setDescription(tagDescription); - return tag; - } - - public static void javaDocTag( - DetailNode tree, Predicate filter, Consumer2 consumer) { - javaDocTag(tree, filter, (tag, value, text) -> consumer.accept(tag, text)); - } - - public static void javaDocTag( - DetailNode tree, - Predicate filter, - Consumer3 consumer) { - if (tree != JavaDocNode.EMPTY_NODE) { - for (var tag : - tree(tree).filter(javadocToken(JavadocCommentsTokenTypes.JAVADOC_BLOCK_TAG)).toList()) { - var tagName = tree(tag).filter(filter).findFirst().orElse(null); - if (tagName != null) { - var tagValue = - tree(tag) - .filter(javadocToken(JavadocCommentsTokenTypes.DESCRIPTION)) - .findFirst() - .orElse(null); - var tagText = tagValue == null ? null : getText(children(tagValue).toList(), true); - consumer.accept(tagName, tagValue, tagText); - } - } - } - } - - public static String operationId(DetailNode javadoc) { - var operationId = new ArrayList(); - javaDocTag( - javadoc, - OPERATION_ID, - (tag, value, text) -> { - operationId.add(text); - }); - return operationId.isEmpty() ? null : operationId.getFirst(); - } -} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MethodDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MethodDoc.java deleted file mode 100644 index 30a4438448..0000000000 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/MethodDoc.java +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.openapi.javadoc; - -import static io.jooby.internal.openapi.javadoc.JavaDocStream.*; - -import java.util.*; - -import com.puppycrawl.tools.checkstyle.api.DetailAST; -import com.puppycrawl.tools.checkstyle.api.DetailNode; -import com.puppycrawl.tools.checkstyle.api.JavadocCommentsTokenTypes; -import com.puppycrawl.tools.checkstyle.api.TokenTypes; -import io.jooby.StatusCode; -import io.jooby.internal.openapi.ResponseExt; -import io.swagger.v3.oas.models.security.SecurityRequirement; - -public class MethodDoc extends JavaDocNode { - private Map parameters; - private List securityRequeriments; - private String operationId; - private Map throwList; - private List parameterTypes = null; - private String returnDoc; - - public MethodDoc(JavaDocParser ctx, DetailAST node, DetailAST javadoc) { - super(ctx, node, javadoc); - throwList = JavaDocTag.throwList(this.javadoc); - operationId = JavaDocTag.operationId(this.javadoc); - securityRequeriments = JavaDocTag.securityRequirement(this.javadoc); - parameters = JavaDocTag.getParametersDoc(this.javadoc); - returnDoc = JavaDocTag.getReturnDoc(this.javadoc); - } - - MethodDoc(JavaDocParser ctx, DetailAST node, DetailNode javadoc) { - super(ctx, node, javadoc); - } - - public String getName() { - return node.findFirstToken(TokenTypes.IDENT).getText(); - } - - public String getOperationId() { - return operationId; - } - - public void setOperationId(String operationId) { - this.operationId = operationId; - } - - public List getSecurityRequirements() { - return securityRequeriments; - } - - public List getParameterTypes() { - if (parameterTypes == null) { - parameterTypes = new ArrayList<>(); - var classDef = - backward(node) - .filter(JavaDocSupport.TYPES) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Class not found: " + node)); - for (var parameter : tree(node).filter(tokens(TokenTypes.PARAMETER_DEF)).toList()) { - var type = - children(parameter) - .filter(tokens(TokenTypes.TYPE)) - .findFirst() - .orElseThrow( - () -> - new IllegalArgumentException( - "Parameter type not found: " - + JavaDocSupport.getSimpleName(parameter))); - parameterTypes.add( - JavaDocSupport.toQualifiedName(classDef, JavaDocSupport.getQualifiedName(type))); - } - } - return parameterTypes; - } - - public MethodDoc markAsVirtual() { - parameterTypes = List.of(); - return this; - } - - public List getJavadocParameterNames() { - return tree(javadoc) - // must be a tag - .filter(javadocToken(JavadocCommentsTokenTypes.JAVADOC_BLOCK_TAG)) - .flatMap( - it -> - tree(it) - .filter(javadocToken(JavadocCommentsTokenTypes.PARAMETER_NAME)) - .map(DetailNode::getText)) - .toList(); - } - - public String getParameterDoc(String name) { - var doc = parameters.get(name); - return doc == null ? null : doc.replace(exampleCode(doc), "").trim(); - } - - public Object getParameterExample(String name) { - return toExamples(exampleCode(parameters.get(name))); - } - - public String getReturnDoc() { - if (returnDoc != null) { - var result = returnDoc.replace(exampleCode(returnDoc), "").trim(); - return result.isEmpty() ? null : result; - } - return null; - } - - public Object getReturnExample() { - return toExamples(returnDoc); - } - - public Map getThrows() { - return throwList; - } -} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/PathDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/PathDoc.java deleted file mode 100644 index 8757c7f783..0000000000 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/PathDoc.java +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.openapi.javadoc; - -import com.puppycrawl.tools.checkstyle.api.DetailAST; - -public class PathDoc extends JavaDocNode { - public PathDoc(JavaDocParser ctx, DetailAST node, DetailAST comment) { - super(ctx, node, comment); - } -} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ScriptDoc.java b/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ScriptDoc.java deleted file mode 100644 index 4cb6626f7b..0000000000 --- a/modules/jooby-openapi/src/main/java/io/jooby/internal/openapi/javadoc/ScriptDoc.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.openapi.javadoc; - -import com.puppycrawl.tools.checkstyle.api.DetailAST; - -public class ScriptDoc extends MethodDoc { - private final String method; - private final String pattern; - private PathDoc path; - - public ScriptDoc( - JavaDocParser ctx, String method, String pattern, DetailAST node, DetailAST javadoc) { - super(ctx, node, javadoc); - this.method = method; - this.pattern = pattern; - } - - public PathDoc getPath() { - return path; - } - - public void setPath(PathDoc path) { - this.path = path; - } - - public String getMethod() { - return method; - } - - public String getPattern() { - return pattern; - } - - @Override - public String toString() { - return method + " " + pattern; - } -} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java index 5b65339e9c..de950dcc94 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java @@ -5,6 +5,8 @@ */ package io.jooby.openapi; +import static io.jooby.internal.openapi.JavaDocMapper.*; + import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -22,8 +24,8 @@ import io.jooby.SneakyThrows; import io.jooby.internal.openapi.*; import io.jooby.internal.openapi.asciidoc.AsciiDocContext; -import io.jooby.internal.openapi.javadoc.JavaDocParser; import io.jooby.internal.openapi.projection.ProjectionParser; +import io.jooby.javadoc.JavaDocParser; import io.swagger.v3.core.util.*; import io.swagger.v3.oas.models.*; import io.swagger.v3.oas.models.info.Info; @@ -237,11 +239,11 @@ public OpenAPIGenerator() {} if (!doc.getExtensions().isEmpty()) { info.setExtensions(doc.getExtensions()); } - doc.getSecuritySchemes().forEach(openapi::addSecuritySchemes); - doc.getServers().forEach(openapi::addServersItem); - doc.getContact().forEach(info::setContact); - doc.getLicense().forEach(info::setLicense); - doc.getTags().forEach(openapi::addTagsItem); + toSecuritySchemes(doc.getSecuritySchemes()).forEach(openapi::addSecuritySchemes); + toServers(doc.getServers()).forEach(openapi::addServersItem); + toContact(doc.getContact()).ifPresent(info::setContact); + toLicense(doc.getLicense()).ifPresent(info::setLicense); + toTags(doc.getTags()).forEach(openapi::addTagsItem); }); } diff --git a/modules/jooby-openapi/src/main/java/module-info.java b/modules/jooby-openapi/src/main/java/module-info.java index 8322c5f6d5..92d3949123 100644 --- a/modules/jooby-openapi/src/main/java/module-info.java +++ b/modules/jooby-openapi/src/main/java/module-info.java @@ -3,6 +3,7 @@ exports io.jooby.openapi; requires io.jooby; + requires io.jooby.javadoc; requires static com.github.spotbugs.annotations; requires typesafe.config; requires org.slf4j; diff --git a/pom.xml b/pom.xml index a223be2368..7d1026cae3 100644 --- a/pom.xml +++ b/pom.xml @@ -1318,6 +1318,14 @@ ${project.build.sourceEncoding} + + + default-testCompile + + false + + + From 9279688c0f137fea4c41da9510389c5fc9ac1c5d Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 21 Mar 2026 11:54:55 -0300 Subject: [PATCH 07/56] feat(mcp): implement zero-reflection APT dispatcher for MCP servers Introduces compile-time annotation processing for the Model Context Protocol (MCP), generating highly optimized, reflection-free dispatchers that bridge untyped JSON-RPC arguments to strongly-typed Jooby controllers. Key implementations: - Add `@McpServer`, `@McpTool`, `@McpPrompt`, `@McpResource`, and `@McpCompletion` annotations for defining MCP capabilities. - Add `@McpParam` to customize schema parameter names and provide LLM descriptions (with planned Javadoc fallback). - Update `MvcRoute` to classify MCP components during construction and isolate them into a dedicated `mcpRoutes` list in `MvcRouter` to prevent REST generator conflicts. - Define `McpService` interface enforcing a strict execution contract with `McpSyncServerExchange` for Context resolution. - Implement `getMcpSourceCode` in `MvcRouter` to generate `*McpServer_` classes featuring: - Zero-reflection `switch/case` routing for tools, prompts, resources, templates, and completions. - Safe extraction and primitive casting from untyped argument maps. - Strict Jackson 3 (`tools.jackson`) registry lookup for complex POJO conversions. - Native Jooby dependency injection support using the existing `constructors()` AST utility. - Interface compliance by throwing `UnsupportedOperationException` for unused MCP capabilities. --- .../io/jooby/annotation/McpCompletion.java | 25 + .../java/io/jooby/annotation/McpParam.java | 38 ++ .../java/io/jooby/annotation/McpPrompt.java | 30 ++ .../java/io/jooby/annotation/McpResource.java | 48 ++ .../java/io/jooby/annotation/McpServer.java | 23 + .../java/io/jooby/annotation/McpTool.java | 30 ++ modules/jooby-apt/pom.xml | 14 +- .../java/io/jooby/apt/JoobyProcessor.java | 25 +- .../io/jooby/internal/apt/HttpMethod.java | 8 +- .../io/jooby/internal/apt/MvcParameter.java | 17 + .../java/io/jooby/internal/apt/MvcRoute.java | 74 +++ .../java/io/jooby/internal/apt/MvcRouter.java | 435 +++++++++++++++++- .../src/test/java/tests/i3804/Issue3804.java | 1 + .../test/java/tests/i3830/ExampleServer.java | 47 ++ .../java/tests/i3830/ExampleServerMcp_.java | 8 + .../src/test/java/tests/i3830/Issue3830.java | 21 + modules/jooby-mcp/pom.xml | 26 ++ .../main/java/io/jooby/mcp/McpService.java | 41 ++ .../src/test/java/issues/i2968/C2968_.java | 32 -- modules/pom.xml | 1 + pom.xml | 7 + 21 files changed, 911 insertions(+), 40 deletions(-) create mode 100644 jooby/src/main/java/io/jooby/annotation/McpCompletion.java create mode 100644 jooby/src/main/java/io/jooby/annotation/McpParam.java create mode 100644 jooby/src/main/java/io/jooby/annotation/McpPrompt.java create mode 100644 jooby/src/main/java/io/jooby/annotation/McpResource.java create mode 100644 jooby/src/main/java/io/jooby/annotation/McpServer.java create mode 100644 jooby/src/main/java/io/jooby/annotation/McpTool.java create mode 100644 modules/jooby-apt/src/test/java/tests/i3830/ExampleServer.java create mode 100644 modules/jooby-apt/src/test/java/tests/i3830/ExampleServerMcp_.java create mode 100644 modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java create mode 100644 modules/jooby-mcp/pom.xml create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java delete mode 100644 modules/jooby-openapi/src/test/java/issues/i2968/C2968_.java diff --git a/jooby/src/main/java/io/jooby/annotation/McpCompletion.java b/jooby/src/main/java/io/jooby/annotation/McpCompletion.java new file mode 100644 index 0000000000..06acf00532 --- /dev/null +++ b/jooby/src/main/java/io/jooby/annotation/McpCompletion.java @@ -0,0 +1,25 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Marks a method as an MCP Completion provider. */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface McpCompletion { + /** + * The identifier of the reference. This is either the Prompt name (e.g., "code_review") or the + * Resource Template URI (e.g., "file:///project/{name}"). + */ + String ref(); + + /** The name of the argument or template variable being completed. */ + String arg(); +} diff --git a/jooby/src/main/java/io/jooby/annotation/McpParam.java b/jooby/src/main/java/io/jooby/annotation/McpParam.java new file mode 100644 index 0000000000..873b4c1c51 --- /dev/null +++ b/jooby/src/main/java/io/jooby/annotation/McpParam.java @@ -0,0 +1,38 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Provides metadata for an MCP Tool or Prompt parameter. */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface McpParam { + /** + * The name of the parameter in the MCP schema. If empty, the Java variable name is used. + * + * @return Parameter name. + */ + String name() default ""; + + /** + * A description of the parameter for the LLM. If empty, it falls back to the @param tag in the + * method's Javadoc. + * + * @return Parameter description. + */ + String description() default ""; + + /** + * Whether this parameter is required. + * + * @return True if required, false otherwise. + */ + boolean required() default true; +} diff --git a/jooby/src/main/java/io/jooby/annotation/McpPrompt.java b/jooby/src/main/java/io/jooby/annotation/McpPrompt.java new file mode 100644 index 0000000000..7d653c3c91 --- /dev/null +++ b/jooby/src/main/java/io/jooby/annotation/McpPrompt.java @@ -0,0 +1,30 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Exposes a method as an MCP Prompt. */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface McpPrompt { + /** + * The name of the prompt. If empty, the method name is used. + * + * @return Prompt name. + */ + String name() default ""; + + /** + * A description of what the prompt provides. + * + * @return Prompt description. + */ + String description() default ""; +} diff --git a/jooby/src/main/java/io/jooby/annotation/McpResource.java b/jooby/src/main/java/io/jooby/annotation/McpResource.java new file mode 100644 index 0000000000..48c1a42670 --- /dev/null +++ b/jooby/src/main/java/io/jooby/annotation/McpResource.java @@ -0,0 +1,48 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Exposes a method as an MCP Resource or Resource Template. + * + *

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

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

    + *
  • Expose server capabilities as tools, resources, and prompts + *
  • Handle client connections and sessions via SSE + *
  • Process protocol messages and events + *
  • Manage server capabilities and tool specifications + *
+ * + *

Usage

+ * + *

Add the module to your application: + * + *

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

Configuration

+ * + *

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

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

Features

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

Multiple servers

+ * + *

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

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

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

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

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

Example usage: - * - *

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

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

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

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

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

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

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

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

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

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

Usage

+ * + *

Add the module to your application: + * + *

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

Configuration

+ * + *

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

Features

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

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

Chaining Invokers

+ * + *

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

Example: MDC Context Propagation

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

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

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

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

Key Features

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

Usage

+ *

Basic Usage

+ * + *

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

Add the module to your application: + *

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

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

Configuration

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

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

Changing the Default Transport

+ * + *

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

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

Features

+ *

Custom Invokers & Telemetry

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

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

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

Multiple Servers

* - *

Multiple servers

+ *

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

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

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

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

Each instance requires its own configuration block: + *

{@code application.conf}: * *

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

Testing and Debugging

+ * + *

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

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

Conversion Strategies

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

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

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

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

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

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

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

Key Features

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

Basic Usage

+ * + *

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

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

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

Changing the Default Transport

+ * + *

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

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

Custom Invokers & Telemetry

+ * + *

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

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

Multiple Servers

+ * + *

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

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

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

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

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

Testing and Debugging

+ * + *

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

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

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

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

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

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

This is especially useful when the method's actual return type is generic (e.g., {@code + * Object} or {@code Response}) due to type erasure, but you want the LLM to know the exact JSON + * shape. + */ + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + @interface From { + Class value(); + } + + /** + * Forces the generation of an array output schema based on the provided class, regardless of the + * global module configuration. + */ + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + @interface ArrayOf { + Class value(); + } + + /** + * Forces the generation of a Map output schema (String keys to the provided class values), + * regardless of the global module configuration. + */ + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + @interface MapOf { + Class value(); + } + + /** + * Explicitly disables output schema generation for this specific tool, even if the global module + * flag is set to true. + */ + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + @interface Off {} +} diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java index 1b546e456c..cc98c1e1cc 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java @@ -154,6 +154,8 @@ public class McpModule implements Extension { private McpInvoker invoker; + private Boolean generateOutputSchema = null; + /** * Creates a new MCP module initialized with the provided generated services. * @@ -208,10 +210,26 @@ public McpModule invoker(@NonNull McpInvoker invoker) { return this; } + /** + * Enabled/disables the generation of the output schema. It is automatically loaded from + * mcp.generateOutputSchema which by default is false. + * + * @param generateOutputSchema true to enable the generation of the output schema. + * @return This module instance for method chaining. + */ + public McpModule generateOutputSchema(boolean generateOutputSchema) { + this.generateOutputSchema = generateOutputSchema; + return this; + } + @Override public void install(@NonNull Jooby app) { var services = app.getServices(); var mcpJsonMapper = services.require(McpJsonMapper.class); + var globalGenerateOutputSchema = + app.getConfig().hasPath("mcp.generateOutputSchema") + ? app.getConfig().getBoolean("mcp.generateOutputSchema") + : Optional.ofNullable(this.generateOutputSchema).orElse(Boolean.FALSE); // invoker McpInvoker firstInvoker = new DefaultMcpInvoker(app); if (this.invoker != null) { @@ -221,8 +239,14 @@ public void install(@NonNull Jooby app) { // Group services by server var mcpServiceMap = new HashMap>(); for (var mcpService : mcpServices) { - var serverKey = Optional.ofNullable(mcpService.serverKey()).orElse("default"); - mcpServiceMap.computeIfAbsent(serverKey, k -> new ArrayList<>()).add(mcpService); + var localGenerateOutputSchemaPath = + MODULE_CONFIG_PREFIX + "." + mcpService.serverKey() + ".generateOutputSchema"; + var localGenerateOutputSchema = + app.getConfig().hasPath(localGenerateOutputSchemaPath) + ? app.getConfig().getBoolean(localGenerateOutputSchemaPath) + : globalGenerateOutputSchema; + mcpService.generateOutputSchema(localGenerateOutputSchema); + mcpServiceMap.computeIfAbsent(mcpService.serverKey(), k -> new ArrayList<>()).add(mcpService); } // Boot everything for (var serverEntry : mcpServiceMap.entrySet()) { diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java index aac6eb5b8e..f8449f4c06 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpService.java @@ -88,4 +88,6 @@ List statelessCompletion * @return The target server key. */ String serverKey(); + + McpService generateOutputSchema(boolean generateOutputSchema); } diff --git a/tests/src/test/java/io/jooby/i3830/UserToolsTest.java b/tests/src/test/java/io/jooby/i3830/UserToolsTest.java index 2edeaa799c..9d06b2bf95 100644 --- a/tests/src/test/java/io/jooby/i3830/UserToolsTest.java +++ b/tests/src/test/java/io/jooby/i3830/UserToolsTest.java @@ -29,6 +29,7 @@ private void setupMcpApp(Jooby app, Extension... extensions) { } app.install( new McpModule(new UserToolsMcp_()) + .generateOutputSchema(true) .transport(McpModule.Transport.STATELESS_STREAMABLE_HTTP)); } From bb61e3dff75f3a9e85e2ddfd14aff501c5b6506d Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 30 Mar 2026 20:22:08 -0300 Subject: [PATCH 46/56] - document new module!!! --- docs/asciidoc/modules/mcp.adoc | 356 +++++++++++++++++++++++++++++ docs/asciidoc/modules/modules.adoc | 1 + pom.xml | 6 + 3 files changed, 363 insertions(+) create mode 100644 docs/asciidoc/modules/mcp.adoc diff --git a/docs/asciidoc/modules/mcp.adoc b/docs/asciidoc/modules/mcp.adoc new file mode 100644 index 0000000000..a193f1705c --- /dev/null +++ b/docs/asciidoc/modules/mcp.adoc @@ -0,0 +1,356 @@ +== MCP + +https://modelcontextprotocol.io/[Model Context Protocol (MCP)] module for Jooby. + +The MCP module provides seamless integration with the Model Context Protocol, allowing your application to act as a standardized AI context server. It automatically bridges your Java/Kotlin methods with LLM clients by exposing them as Tools, Resources, and Prompts. + +=== Usage + +1) Add the dependencies (Jooby MCP, Jackson, and the APT processor): + +[dependency, artifactId="jooby-jackson3:Jackson Module, jooby-mcp-jackson3:MCP Jackson Module, jooby-mcp:MCP Module"] +. + +[NOTE] +==== +You must configure the Jooby Annotation Processor (APT) in your build. The MCP module relies on APT to generate high-performance routing code with zero reflection overhead. +==== + +2) Define a service and expose capabilities via annotations: + +.Java +[source, java, role="primary"] +---- +import io.jooby.annotation.mcp.McpTool; + +public class CalculatorService { + + @McpTool(description = "Adds two numbers together") + public int add(int a, int b) { + return a + b; + } +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.annotation.mcp.McpTool + +class CalculatorService { + + @McpTool(description = "Adds two numbers together") + fun add(a: Int, b: Int): Int { + return a + b + } +} +---- + +3) Install the module using Jackson and the generated service: + +.Java +[source, java, role="primary"] +---- +import io.jooby.jackson3.Jackson3Module; +import io.jooby.mcp.jackson3.McpJackson3Module; +import io.jooby.mcp.McpModule; + +{ + install(new Jackson3Module()); <1> + + install(new McpJackson3Module()); <2> + + install(new McpModule(new CalculatorServiceMcp_())); <3> +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.jackson3.Jackson3Module +import io.jooby.mcp.jackson3.McpJackson3Module +import io.jooby.mcp.McpModule + +{ + install(Jackson3Module()) <1> + + install(McpJackson3Module()) <2> + + install(McpModule(CalculatorServiceMcp_())) <3> +} +---- + +<1> Install JSON support (Jackson is required for MCP JSON-RPC serialization). For Jackson 2, use `JacksonModule()` instead. +<2> Install MCP JSON support. For Jackson 2, use `McpJackson2Module()` instead. +<3> Install the MCP module with the APT-generated `McpService` dispatcher. The generated class always ends with the `Mcp_` suffix. + +=== Core Capabilities + +The module uses annotations to expose application logic to AI clients: + +- `@McpTool`: Exposes a method as an executable tool. The module automatically generates JSON schemas for the parameters. +- `@McpPrompt`: Exposes a method as a reusable prompt template. +- `@McpResource` / `@McpResourceTemplate`: Exposes static or dynamic data as an MCP resource (e.g., `file://config`). +- `@McpCompletion`: Handles auto-completion logic for prompts and resources. + +=== Schema Descriptions & Javadoc + +Rich descriptions are highly recommended for LLM usage so the model understands exactly what a tool, prompt, or resource does. You can provide these descriptions directly in the MCP annotations (e.g., `@McpTool(description = "...")`). + +However, if you omit them, the Jooby Annotation Processor will automatically extract descriptions from your standard Javadoc comments, including method descriptions and `@param` tags. + +.Javadoc Extraction Example +[source, java] +---- +import io.jooby.annotation.mcp.McpTool; + +public class WeatherService { + + /** + * Retrieves the current weather forecast for a given location. + * + * @param location The city and state, e.g., "San Francisco, CA" + * @param units The temperature unit to use (celsius or fahrenheit) + */ + @McpTool + public WeatherForecast getWeather(String location, String units) { + // ... + } +} +---- + +In the example above, the LLM will automatically receive the method's Javadoc summary as the tool description, and the `@param` comments as the descriptions for the `location` and `units` JSON schema properties. + +=== Transports + +By default, the MCP module starts a single server using the `STREAMABLE_HTTP` transport. You can easily switch to other supported transports such as `SSE` (Server-Sent Events), `WEBSOCKET`, or `STATELESS_STREAMABLE_HTTP`. + +.Changing Transport +[source, java] +---- +import io.jooby.mcp.McpModule.Transport; + +{ + install(new McpModule(new CalculatorServiceMcp_()) + .transport(Transport.WEBSOCKET)); +} +---- + +=== Output Schema Generation + +By default, the framework does *not* generate JSON output schemas for tools in order to save LLM context window tokens. You can enable it globally on the module, or override it per-method using the `@McpOutputSchema` annotation. + +.Programmatic Global Configuration +[source, java] +---- +{ + // Enable output schema generation for all tools by default + install(new McpModule(new CalculatorServiceMcp_()) + .generateOutputSchema(true)); +} +---- + +Alternatively, you can control output schema generation using your application configuration properties. Configuration properties always take precedence over the programmatic setup, and allow you to configure behavior per-server. + +.application.conf +[source, properties] +---- +# Global fallback for all servers +mcp.generateOutputSchema = true + +# Per-server override (takes precedence over the global flag) +mcp.calculator.generateOutputSchema = false +---- + +You can explicitly override the global/config flag and bypass Java type erasure using nested `@McpOutputSchema` annotations: + +.Per-Method Override +[source, java] +---- +import io.jooby.annotation.mcp.McpTool; +import io.jooby.annotation.mcp.McpOutputSchema; + +public class UserService { + + @McpTool + @McpOutputSchema.ArrayOf(User.class) <1> + public List findUsers(String query) { + // ... + } + + @McpTool + @McpOutputSchema.Off <2> + public HugeDataset getBigData() { + // ... + } +} +---- + +<1> Forces array schema generation for `User`, overriding generic `Object` erasure and the global/config flag. +<2> Explicitly disables schema generation for this specific tool. + +=== Custom Invokers & Telemetry + +You can inject custom logic (like SLF4J MDC context propagation, tracing, or custom error handling) around every tool, prompt, or resource execution by providing an `McpInvoker`. + +[NOTE] +==== +Invokers are chained. You can register multiple invokers and they will wrap the execution in the order they were added: +`install(new McpModule(...).invoker(new LoggingInvoker()).invoker(new SecurityInvoker()));` +==== + +.MDC Invoker Example +[source, java] +---- +import io.jooby.mcp.McpInvoker; +import io.jooby.mcp.McpOperation; +import org.slf4j.MDC; + +public class MdcMcpInvoker implements McpInvoker { + @Override + public R invoke(McpOperation operation, SneakyThrows.Supplier action) { + try { + MDC.put("mcp.id", operation.id()); <1> + MDC.put("mcp.class", operation.className()); + MDC.put("mcp.method", operation.methodName()); + return action.get(); <2> + } finally { + MDC.remove("mcp.id"); + MDC.remove("mcp.class"); + MDC.remove("mcp.method"); + } + } +} + +{ + install(new McpModule(new CalculatorServiceMcp_()) + .invoker(new MdcMcpInvoker())); <3> +} +---- + +<1> Extract rich contextual data from the `McpOperation` record. +<2> Proceed with the execution chain. +<3> Register the invoker. Jooby will safely map any business exceptions thrown by your action into valid MCP JSON-RPC errors. + +=== Multiple Servers + +You can run multiple, completely isolated MCP server instances within the same Jooby application by utilizing the `@McpServer("serverName")` annotation on your service classes. + +When bootstrapping multiple servers, you *must* provide configuration for each server in your `application.conf`. + +.application.conf +[source, properties] +---- +mcp.default.name = "default-mcp-server" +mcp.default.version = "1.0.0" + +mcp.calculator.name = "calculator-mcp-server" +mcp.calculator.version = "1.0.0" +mcp.calculator.transport = "sse" +mcp.calculator.mcpEndpoint = "/mcp/calculator/sse" +---- + +.Java Bootstrap +[source, java] +---- +{ + // Bootstraps services each on their corresponding service base based on the @McpServer mappings + install(new McpModule( + new DefaultServiceMcp_(), + new CalculatorServiceMcp_() + )); +} +---- + +=== Debugging & Testing (MCP Inspector) + +When building an MCP server, it is highly recommended to test your tools, prompts, and resources locally before connecting them to a real LLM client. + +The `McpInspectorModule` provides a built-in, interactive web UI that acts as a dummy LLM client. It allows you to manually trigger tools, view generated output schemas, inspect resources, and debug JSON-RPC payloads in real-time. + +1) Add the inspector dependency (typically as a `test` or `development` dependency): + +[dependency, artifactId="jooby-mcp-inspector:MCP Inspector"] +. + +2) Install the module, ensuring it is only active during development: + +.Java Bootstrap +[source, java, role="primary"] +---- +import io.jooby.mcp.McpModule; +import io.jooby.mcp.inspector.McpInspectorModule; + +{ + install(new McpModule( + new DefaultServiceMcp_(), + new CalculatorServiceMcp_() + )); + + // Only enable the inspector UI in the 'dev' environment + if (getEnvironment().isActive("dev")) { + install(new McpInspectorModule()); + } +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.mcp.McpModule +import io.jooby.mcp.inspector.McpInspectorModule + +{ + install(McpModule( + DefaultServiceMcp_(), + CalculatorServiceMcp_() + )) + + // Only enable the inspector UI in the 'dev' environment + if (environment.isActive("dev")) { + install(McpInspectorModule()) + } +} +---- + +Once the application starts, open your browser and navigate to the default inspector route: `http://localhost:8080/mcp-inspector`. + +==== Inspector Configuration + +You can customize the behavior and mounting point of the Inspector UI using its programmatic builder methods: + +.Programmatic Setup +[source, java, role="primary"] +---- +{ + if (getEnvironment().isActive("dev")) { + install(new McpInspectorModule() + .path("/debug/mcp") <1> + .defaultServer("calculator-mcp-server") <2> + .autoConnect(true) <3> + ); + } +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +{ + if (environment.isActive("dev")) { + install(McpInspectorModule() + .path("/debug/mcp") <1> + .defaultServer("calculator-mcp-server") <2> + .autoConnect(true) <3> + ) + } +} +---- + +<1> Changes the base path for the Inspector UI (defaults to `/mcp-inspector`). +<2> Automatically selects a specific named server in the UI dropdown when dealing with multiple MCP servers. +<3> Automatically connects to the selected server as soon as the page loads. + +=== Special Thanks + +A special thanks to https://github.com/kliushnichenko[kliushnichenko]. This MCP module was heavily inspired by and based upon their foundational work and contributions to the ecosystem. diff --git a/docs/asciidoc/modules/modules.adoc b/docs/asciidoc/modules/modules.adoc index 3458e56617..e9c396b73b 100644 --- a/docs/asciidoc/modules/modules.adoc +++ b/docs/asciidoc/modules/modules.adoc @@ -8,6 +8,7 @@ Modules are distributed as separate dependencies. Below is the catalog of offici ==== AI * link:{uiVersion}/modules/langchain4j[LangChain4j]: Supercharge your Java application with the power of LLMs. + * link:{uiVersion}/modules/mcp[MCP]: The MCP module provides seamless integration with the Model Context Protocol. ==== Cloud * link:{uiVersion}/modules/awssdkv2[AWS-SDK v2]: Amazon Web Service module SDK 2. diff --git a/pom.xml b/pom.xml index d5ce0cadef..c7244e22e5 100644 --- a/pom.xml +++ b/pom.xml @@ -614,6 +614,12 @@ ${jooby.version} + + io.jooby + jooby-mcp-inspector + ${jooby.version} + + com.github.ben-manes.caffeine caffeine From 6490513e6354fd47f6f5b3c3d5d4d3cacb278dda Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 30 Mar 2026 20:33:49 -0300 Subject: [PATCH 47/56] doc: add note on main features --- docs/asciidoc/index.adoc | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/asciidoc/index.adoc b/docs/asciidoc/index.adoc index 518d583ee1..d2d91d82ab 100644 --- a/docs/asciidoc/index.adoc +++ b/docs/asciidoc/index.adoc @@ -59,6 +59,7 @@ fun main(args: Array) { * **Execution Options:** Choose between <>. * **Reactive Ready:** Support for <> (CompletableFuture, RxJava, Reactor, Mutiny, and Kotlin Coroutines). * **Server Choice:** Run on https://www.eclipse.org/jetty[Jetty], https://netty.io[Netty], https://vertx.io[Vert.x], or http://undertow.io[Undertow]. +* **AI Ready:** Seamlessly expose your application's data and functions to Large Language Models (LLMs) using the first-class link:modules/mcp[Model Context Protocol (MCP)] module. * **Extensible:** Scale to a full-stack framework using extensions and link:modules[modules]. [TIP] From 51558bc33439b3ca00b18397bcb5db737ffc40d9 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Tue, 31 Mar 2026 06:52:52 -0300 Subject: [PATCH 48/56] build: use random port for CI --- .../test/java/io/jooby/test/AppPerTestUsingJoobyTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/jooby-test/src/test/java/io/jooby/test/AppPerTestUsingJoobyTest.java b/modules/jooby-test/src/test/java/io/jooby/test/AppPerTestUsingJoobyTest.java index 8f2ed36b5b..de1a55f52b 100644 --- a/modules/jooby-test/src/test/java/io/jooby/test/AppPerTestUsingJoobyTest.java +++ b/modules/jooby-test/src/test/java/io/jooby/test/AppPerTestUsingJoobyTest.java @@ -18,7 +18,7 @@ public class AppPerTestUsingJoobyTest { static OkHttpClient client = new OkHttpClient(); - @JoobyTest(value = TestApp.class) + @JoobyTest(value = TestApp.class, port = 0) public void sayHi(String serverPath) throws IOException { Request request = new Request.Builder().url(serverPath).build(); @@ -27,7 +27,7 @@ public void sayHi(String serverPath) throws IOException { } } - @JoobyTest(value = TestApp.class) + @JoobyTest(value = TestApp.class, port = 0) public void sayH2i(int serverPort) throws IOException { Request request = new Request.Builder().url("http://localhost:" + serverPort + "/test").build(); @@ -36,7 +36,7 @@ public void sayH2i(int serverPort) throws IOException { } } - @JoobyTest(value = TestArgApp.class, factoryMethod = "createApp") + @JoobyTest(value = TestArgApp.class, factoryMethod = "createApp", port = 0) public void shouldUseFactoryMethod(int serverPort) throws IOException { Request request = new Request.Builder().url("http://localhost:" + serverPort + "/").build(); From 2cd624252de27f9b2cfedf1c2e877c8fc62a6474 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Tue, 31 Mar 2026 07:19:59 -0300 Subject: [PATCH 49/56] build: remove jooby-javadoc from bom --- modules/jooby-bom/bom.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/jooby-bom/bom.groovy b/modules/jooby-bom/bom.groovy index 64ae923d46..4f210d3668 100644 --- a/modules/jooby-bom/bom.groovy +++ b/modules/jooby-bom/bom.groovy @@ -4,7 +4,7 @@ import java.nio.file.* def modules = project.collectedProjects.find {it.name == 'modules'}.modules.sort() modules.add(0, 'jooby'); -def skip = ["jooby-bom", "jooby-gradle-setup", "jooby-apt"] +def skip = ["jooby-bom", "jooby-gradle-setup", "jooby-apt", "jooby-javadoc"] def depsw = new StringWriter() def deps = new MarkupBuilder(depsw) From 5d91b8e64f82a1ad97b29596cfb2b86aa24c2d82 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Tue, 31 Mar 2026 07:24:35 -0300 Subject: [PATCH 50/56] build: code cleanup on McpRouter.java --- .../java/io/jooby/internal/apt/McpRouter.java | 822 +++++++----------- 1 file changed, 324 insertions(+), 498 deletions(-) diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java index 4996e5a4ee..21da72b576 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java @@ -12,8 +12,12 @@ import java.nio.file.Files; import java.nio.file.Paths; import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.Set; import javax.lang.model.element.ElementKind; import javax.lang.model.element.ExecutableElement; @@ -69,58 +73,56 @@ private String getMcpServerKey() { return "default"; } - /** - * Find completion target must be a prompt or resource. - * - * @param ref Prompt name or resource uri. - * @return Method name. - */ private String findTargetMethodName(String ref) { for (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(); - } + if ((route.isMcpPrompt() || route.isMcpResource() || route.isMcpResourceTemplate()) + && ref.equals(getMcpRouteName(route))) { + return route.getMethodName(); } } return "mcpTarget" + Math.abs(ref.hashCode()); } + private String getMcpRouteName(McpRoute route) { + String annotationName = null; + String attrName = "name"; + + if (route.isMcpTool()) { + annotationName = "io.jooby.annotation.mcp.McpTool"; + } else if (route.isMcpPrompt()) { + annotationName = "io.jooby.annotation.mcp.McpPrompt"; + } else if (route.isMcpResource() || route.isMcpResourceTemplate()) { + annotationName = "io.jooby.annotation.mcp.McpResource"; + attrName = "uri"; + } + + if (annotationName != null) { + var ann = AnnotationSupport.findAnnotationByName(route.getMethod(), annotationName); + if (ann != null) { + final String finalAttrName = attrName; + var name = + AnnotationSupport.findAnnotationValue(ann, finalAttrName::equals).stream() + .findFirst() + .orElse(""); + if (!name.isEmpty()) return name; + } + } + return route.getMethodName(); + } + + private String getMcpRouteType(McpRoute route) { + if (route.isMcpTool()) return "tools"; + if (route.isMcpPrompt()) return "prompts"; + if (route.isMcpResource() || route.isMcpResourceTemplate()) return "resources"; + return ""; + } + @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) -> @@ -134,56 +136,74 @@ public String toSourceCode(boolean kt) throws IOException { 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 = + var completionGroups = buildCompletionGroups(); + var allCompletionRefs = buildAllCompletionRefs(prompts, resources, completionGroups); + + appendFields(buffer, kt); + appendCapabilities(buffer, kt, tools, prompts, resources, completionGroups); + appendGenerateOutputSchema(buffer, kt); + appendServerKey(buffer, kt); + + // Generate both stateful and stateless completion methods + appendCompletions(buffer, kt, false, allCompletionRefs, completionGroups); + appendCompletions(buffer, kt, true, allCompletionRefs, completionGroups); + + // Generate both stateful and stateless install methods + appendInstall(buffer, kt, false, tools); + appendInstall(buffer, kt, true, tools); + + // Append handler methods + for (var route : getRoutes()) { + route.generateMcpDefinitionMethod(kt).forEach(buffer::append); + route.generateMcpHandlerMethod(kt).forEach(buffer::append); + } + appendCompletionHandlers(buffer, kt, completionGroups); + + return getTemplate(kt) + .replace("${packageName}", getPackageName()) + .replace("${imports}", imports) + .replace("${className}", generateTypeName) + .replace("${generatedClassName}", mcpClassName) + .replace("${implements}", "io.jooby.mcp.McpService") + .replace("${constructors}", constructors(mcpClassName, kt)) + .replace("${methods}", trimr(buffer)); + } + + private Map> buildCompletionGroups() { + var groups = new LinkedHashMap>(); + for (var route : getRoutes().stream().filter(McpRoute::isMcpCompletion).toList()) { + var ann = AnnotationSupport.findAnnotationByName( route.getMethod(), "io.jooby.annotation.mcp.McpCompletion"); String ref = - AnnotationSupport.findAnnotationValue(annotation, "value"::equals).stream() + AnnotationSupport.findAnnotationValue(ann, "value"::equals).stream() .findFirst() .orElse(null); if (ref == null || ref.isEmpty()) { ref = - AnnotationSupport.findAnnotationValue(annotation, "ref"::equals).stream() + AnnotationSupport.findAnnotationValue(ann, "ref"::equals).stream() .findFirst() .orElse(""); } - completionGroups.computeIfAbsent(ref, k -> new ArrayList<>()).add(route); + groups.computeIfAbsent(ref, k -> new ArrayList<>()).add(route); } + return groups; + } - // 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()); + private Set buildAllCompletionRefs( + List prompts, + List resources, + Map> completionGroups) { + var refs = new LinkedHashSet(); + prompts.forEach(r -> refs.add(getMcpRouteName(r))); + resources.stream() + .filter(McpRoute::isMcpResourceTemplate) + .forEach(r -> refs.add(getMcpRouteName(r))); + refs.addAll(completionGroups.keySet()); + return refs; + } + private void appendFields(StringBuilder buffer, boolean kt) { if (kt) { buffer.append( statement( @@ -196,8 +216,15 @@ public String toSourceCode(boolean kt) throws IOException { buffer.append( statement(indent(4), "private boolean generateOutputSchema = false", semicolon(kt))); } + } - // --- capabilities() --- + private void appendCapabilities( + StringBuilder buffer, + boolean kt, + List tools, + List prompts, + List resources, + Map completions) { if (kt) { buffer.append( statement( @@ -213,21 +240,20 @@ public String toSourceCode(boolean kt) throws IOException { + " capabilities(io.modelcontextprotocol.spec.McpSchema.ServerCapabilities.Builder" + " capabilities) {")); } - if (!tools.isEmpty()) { + + if (!tools.isEmpty()) buffer.append(statement(indent(6), "capabilities.tools(true)", semicolon(kt))); - } - if (!prompts.isEmpty()) { + if (!prompts.isEmpty()) buffer.append(statement(indent(6), "capabilities.prompts(true)", semicolon(kt))); - } - if (!resources.isEmpty()) { + if (!resources.isEmpty()) buffer.append(statement(indent(6), "capabilities.resources(true, true)", semicolon(kt))); - } - if (!completionGroups.isEmpty()) { + if (!completions.isEmpty()) buffer.append(statement(indent(6), "capabilities.completions()", semicolon(kt))); - } + buffer.append(statement(indent(4), "}\n")); + } - // --- generateOutputSchema() --- + private void appendGenerateOutputSchema(StringBuilder buffer, boolean kt) { if (kt) { buffer.append( statement( @@ -249,8 +275,9 @@ public String toSourceCode(boolean kt) throws IOException { buffer.append(statement(indent(6), "return this", semicolon(kt))); buffer.append(statement(indent(4), "}\n")); } + } - // --- serverKey() --- + private void appendServerKey(StringBuilder buffer, boolean kt) { var serverName = getMcpServerKey(); if (kt) { buffer.append(statement(indent(4), "override fun serverKey(): String {")); @@ -261,30 +288,44 @@ public String toSourceCode(boolean kt) throws IOException { buffer.append(statement(indent(6), "return ", string(serverName), semicolon(kt))); } buffer.append(statement(indent(4), "}\n")); + } + + private void appendCompletions( + StringBuilder buffer, + boolean kt, + boolean isStateless, + Set allRefs, + Map> groups) { + String methodName = isStateless ? "statelessCompletions" : "completions"; + String featureClass = isStateless ? "McpStatelessServerFeatures" : "McpServerFeatures"; - // --- completions() --- if (kt) { buffer.append( statement( indent(4), - "override fun completions(app: io.jooby.Jooby):" - + " List" - + " {")); + "override fun ", + methodName, + "(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()")); + "val completions = mutableListOf()")); } else { buffer.append(statement(indent(4), "@Override")); buffer.append( statement( indent(4), - "public" - + " java.util.List" - + " completions(io.jooby.Jooby app) {")); + "public java.util.List ", + methodName, + "(io.jooby.Jooby app) {")); buffer.append( statement( indent(6), @@ -293,12 +334,13 @@ public String toSourceCode(boolean kt) throws IOException { buffer.append( statement( indent(6), - "var completions = new" - + " java.util.ArrayList()", + "var completions = new java.util.ArrayList()", semicolon(kt))); } - for (var ref : allCompletionRefs) { + for (var ref : allRefs) { var isResource = ref.contains("://"); var refObj = isResource @@ -306,40 +348,35 @@ public String toSourceCode(boolean kt) throws IOException { : "io.modelcontextprotocol.spec.McpSchema.PromptReference"; String lambda; - if (completionGroups.containsKey(ref)) { + if (groups.containsKey(ref)) { var targetMethod = findTargetMethodName(ref); var handlerName = targetMethod + "CompletionHandler"; - var operationId = "completions/" + ref; + var operationArg = generateOperationArg(kt, "completions/" + ref, targetMethod); - String operationArg = - kt - ? "io.jooby.mcp.McpOperation(" - + string(operationId) - + ", " - + string(targetClassName) - + ", " - + string(targetMethod) - + ")" - : "new io.jooby.mcp.McpOperation(" - + string(operationId) - + ", " - + string(targetClassName) - + ", " - + string(targetMethod) - + ")"; + String invokeArgs = + isStateless ? "null, ctx, req" : "exchange, exchange.transportContext(), req"; + String lambdaArgs = isStateless ? "ctx, req" : "exchange, req"; lambda = kt - ? "{ exchange, req -> invoker.invoke(" + ? "{ " + + lambdaArgs + + " -> invoker.invoke(" + operationArg + ") { this." + handlerName - + "(exchange, exchange.transportContext(), req) } }" - : "(exchange, req) -> invoker.invoke(" + + "(" + + invokeArgs + + ") } }" + : "(" + + lambdaArgs + + ") -> invoker.invoke(" + operationArg + ", () -> this." + handlerName - + "(exchange, exchange.transportContext(), req))"; + + "(" + + invokeArgs + + "))"; } else { lambda = kt @@ -348,11 +385,15 @@ public String toSourceCode(boolean kt) throws IOException { + " io.jooby.mcp.McpResult(this.json).toCompleteResult(java.util.List.of())"; } + String specification = + "io.modelcontextprotocol.server." + featureClass + ".SyncCompletionSpecification"; if (kt) { buffer.append( statement( indent(6), - "completions.add(io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(", + "completions.add(", + specification, + "(", refObj, "(", string(ref), @@ -363,9 +404,9 @@ public String toSourceCode(boolean kt) throws IOException { buffer.append( statement( indent(6), - "completions.add(new" - + " io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new" - + " ", + "completions.add(new ", + specification, + "(new ", refObj, "(", string(ref), @@ -375,377 +416,159 @@ public String toSourceCode(boolean kt) throws IOException { semicolon(kt))); } } + buffer.append(statement(indent(6), "return completions", semicolon(kt))); buffer.append(statement(indent(4), "}\n")); + } + + private void appendInstall( + StringBuilder buffer, boolean kt, boolean isStateless, List tools) { + String serverType = + isStateless + ? "io.modelcontextprotocol.server.McpStatelessSyncServer" + : "io.modelcontextprotocol.server.McpSyncServer"; + String featuresClass = isStateless ? "McpStatelessServerFeatures" : "McpServerFeatures"; - // --- statelessCompletions() --- if (kt) { + buffer.append(statement(indent(4), "@Throws(Exception::class)")); 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)")); + indent(4), "override fun install(app: io.jooby.Jooby, server: ", serverType, ") {")); buffer.append( statement( indent(6), - "val completions =" - + " mutableListOf()")); + "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" - + " java.util.List" - + " statelessCompletions(io.jooby.Jooby app) {")); + "public void install(io.jooby.Jooby app, ", + serverType, + " server) throws Exception {")); buffer.append( statement( indent(6), - "var invoker = app.require(io.jooby.mcp.McpInvoker.class)", + "this.json = app.require(io.modelcontextprotocol.json.McpJsonMapper.class)", semicolon(kt))); buffer.append( statement( indent(6), - "var completions = new" - + " java.util.ArrayList()", + "var invoker = app.require(io.jooby.mcp.McpInvoker.class)", 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) { + if (!tools.isEmpty()) { 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, - "))", + "var schemaGenerator =" + + " app.require(com.github.victools.jsonschema.generator.SchemaGenerator.class)", semicolon(kt))); } } - buffer.append(statement(indent(6), "return completions", semicolon(kt))); - buffer.append(statement(indent(4), "}\n")); + buffer.append(System.lineSeparator()); - // --- 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 + ") {")); + for (var route : getRoutes()) { + var methodName = route.getMethodName(); + var mcpName = getMcpRouteName(route); + var mcpType = getMcpRouteType(route); + if (mcpType.isEmpty()) continue; + + var operationArg = generateOperationArg(kt, mcpType + "/" + mcpName, methodName); + + String invokeArgs = + isStateless ? "null, ctx, req" : "exchange, exchange.transportContext(), req"; + String lambdaArgs = isStateless ? "ctx, req" : "exchange, req"; + + var lambda = + kt + ? "{ " + + lambdaArgs + + " -> invoker.invoke(" + + operationArg + + ") { this." + + methodName + + "(" + + invokeArgs + + ") } }" + : "(" + + lambdaArgs + + ") -> invoker.invoke(" + + operationArg + + ", () -> this." + + methodName + + "(" + + invokeArgs + + "))"; + + String prefix = kt ? "" : "new "; + String serverMethod = "io.modelcontextprotocol.server." + featuresClass + "."; + + if (route.isMcpTool()) { 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 {")); + "server.addTool(", + prefix, + serverMethod, + "SyncToolSpecification(", + methodName, + "ToolSpec(schemaGenerator), ", + lambda, + "))", + semicolon(kt))); + } else if (route.isMcpPrompt()) { buffer.append( statement( indent(6), - "this.json =" + " app.require(io.modelcontextprotocol.json.McpJsonMapper.class)", + "server.addPrompt(", + prefix, + serverMethod, + "SyncPromptSpecification(", + methodName, + "PromptSpec(), ", + lambda, + "))", semicolon(kt))); + } else if (route.isMcpResource() || route.isMcpResourceTemplate()) { + var isTemplate = route.isMcpResourceTemplate(); + var specType = + isTemplate ? "SyncResourceTemplateSpecification" : "SyncResourceSpecification"; + var addMethod = isTemplate ? "server.addResourceTemplate(" : "server.addResource("; + var defMethod = isTemplate ? "ResourceTemplateSpec()" : "ResourceSpec()"; + buffer.append( statement( indent(6), - "var invoker = app.require(io.jooby.mcp.McpInvoker.class)", + addMethod, + prefix, + serverMethod, + specType, + "(", + methodName, + defMethod, + ", ", + lambda, + "))", 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); } + buffer.append(statement(indent(4), "}\n")); + } - // --- Generate Unified Completion Handlers --- + private void appendCompletionHandlers( + StringBuilder buffer, boolean kt, Map> completionGroups) { for (var entry : completionGroups.entrySet()) { var ref = entry.getKey(); var handlerName = findTargetMethodName(ref) + "CompletionHandler"; - var routes = entry.getValue(); if (kt) { buffer.append( @@ -791,56 +614,56 @@ public String toSourceCode(boolean kt) throws IOException { buffer.append(statement(indent(6), "return switch (targetArg) {")); } - for (var route : routes) { + for (var route : entry.getValue()) { String targetArgName = null; - var invokeArgs = new java.util.ArrayList(); + var invokeArgs = new 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")) { + 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")) { + else if (type.equals("io.modelcontextprotocol.common.McpTransportContext")) invokeArgs.add("transportContext"); - } else { + 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 (targetArgName != null) { + 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), "}")); + } } } @@ -859,17 +682,20 @@ public String toSourceCode(boolean kt) throws IOException { semicolon(kt))); buffer.append(statement(indent(6), "}", semicolon(kt))); } - buffer.append(statement(indent(4), "}", System.lineSeparator())); + buffer.append(statement(indent(4), "}\n")); } + } - return template - .replace("${packageName}", packageName) - .replace("${imports}", imports) - .replace("${className}", generateTypeName) - .replace("${generatedClassName}", mcpClassName) - .replace("${implements}", "io.jooby.mcp.McpService") - .replace("${constructors}", constructors(mcpClassName, kt)) - .replace("${methods}", trimr(buffer)); + private String generateOperationArg(boolean kt, String operationId, String targetMethod) { + String prefix = kt ? "" : "new "; + return prefix + + "io.jooby.mcp.McpOperation(" + + string(operationId) + + ", " + + string(getTargetType().toString()) + + ", " + + string(targetMethod) + + ")"; } public Optional getMethodDoc(String methodName, List types) { From 2e93661878cf723c947113502807303fb86beb4a Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Tue, 31 Mar 2026 10:45:03 -0300 Subject: [PATCH 51/56] - build: remove security vulnerabilites from classpath --- modules/jooby-javadoc/pom.xml | 50 +++++++++++++++++++++-------------- modules/jooby-openapi/pom.xml | 6 +++++ pom.xml | 6 ++--- 3 files changed, 39 insertions(+), 23 deletions(-) diff --git a/modules/jooby-javadoc/pom.xml b/modules/jooby-javadoc/pom.xml index dd16211675..40dab9fc6e 100644 --- a/modules/jooby-javadoc/pom.xml +++ b/modules/jooby-javadoc/pom.xml @@ -13,11 +13,21 @@ jooby-javadoc jooby-javadoc - - - - + + + org.apache.commons + commons-lang3 + 3.20.0 + + + + + org.apache.commons + commons-text + 1.15.0 + + com.puppycrawl.tools checkstyle @@ -71,20 +81,20 @@ - - - - - - - - - - - - - - - - + + + + org.apache.maven.plugins + maven-jar-plugin + ${maven-jar-plugin.version} + + + + io.jooby.javadoc + + + + + + diff --git a/modules/jooby-openapi/pom.xml b/modules/jooby-openapi/pom.xml index 5034fa80d3..e79d2cfeb5 100644 --- a/modules/jooby-openapi/pom.xml +++ b/modules/jooby-openapi/pom.xml @@ -71,6 +71,12 @@ 1.21.0 + + org.mozilla + rhino + 1.9.1 + + io.swagger.parser.v3 swagger-parser diff --git a/pom.xml b/pom.xml index d80fb016b7..8189bd4f6a 100644 --- a/pom.xml +++ b/pom.xml @@ -113,7 +113,7 @@ 5.0.10 - 2.2.45 + 2.2.46 2.1.39 2.0.0-rc.20 @@ -134,12 +134,12 @@ 5.3.2 0.13.0 - 6.3.3 + 6.4.0 2.5.2 9.2.1 8.17.0 1.12.797 - 4.17.0 + 4.18.1 1.9.3 2.21.0 From 85cb2682e6298048f18e9b5cbeefd12b92561ed6 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Tue, 31 Mar 2026 10:51:04 -0300 Subject: [PATCH 52/56] undertow 2.4.0 fix #3890 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 8189bd4f6a..91ce9a5cc7 100644 --- a/pom.xml +++ b/pom.xml @@ -107,7 +107,7 @@ 9.9.1 - 2.3.24.Final + 2.4.0.RC4 12.1.7 4.2.12.Final 5.0.10 From e262dac5e1909d3ec99cf7cadc401a105d81eaa5 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Tue, 31 Mar 2026 10:53:59 -0300 Subject: [PATCH 53/56] tRPC: change the way the generated class is registered - fix #3888 --- docs/asciidoc/tRPC.adoc | 12 ++++++------ jooby/src/main/java/io/jooby/Jooby.java | 14 ++++++++++++++ .../io/jooby/i3863/AbstractTrpcProtocolTest.java | 2 +- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/docs/asciidoc/tRPC.adoc b/docs/asciidoc/tRPC.adoc index 6f65633f89..88fe7fb329 100644 --- a/docs/asciidoc/tRPC.adoc +++ b/docs/asciidoc/tRPC.adoc @@ -19,11 +19,11 @@ import io.jooby.rpc.trpc.TrpcModule; public class App extends Jooby { { - install(new JacksonModule()); // <1> + install(new JacksonModule()); // <1> - install(new TrpcModule()); // <2> + install(new TrpcModule()); // <2> - install(new MovieService_()); // <3> + trpc(new MovieServiceTrpc_()); // <3> } } ---- @@ -36,11 +36,11 @@ import io.jooby.json.JacksonModule import io.jooby.rpc.trpc.TrpcModule class App : Kooby({ - install(JacksonModule()) // <1> + install(JacksonModule()) // <1> - install(TrpcModule()) // <2> + install(TrpcModule()) // <2> - install(MovieService_()) // <3> + trpc(MovieServiceTrpc_()) // <3> }) ---- diff --git a/jooby/src/main/java/io/jooby/Jooby.java b/jooby/src/main/java/io/jooby/Jooby.java index fb6a341944..df39736716 100644 --- a/jooby/src/main/java/io/jooby/Jooby.java +++ b/jooby/src/main/java/io/jooby/Jooby.java @@ -535,6 +535,20 @@ public Jooby jsonRpc(@NonNull JsonRpcService service) { return jsonRpc("/rpc", service); } + /** + * Registers a tRPC router within the application. + * + *

This method provides a native DSL entry point for integrating a tRPC router. It provisions + * the tRPC extension by delegating to the underlying {@link #mvc(Extension)} route registration + * mechanism. + * + * @param trpcRouter The tRPC router extension to register. Must not be null. + * @return A {@link Route.Set} containing the registered tRPC endpoints. + */ + public Route.Set trpc(@NonNull Extension trpcRouter) { + return mvc(trpcRouter); + } + /** * Add controller routes. * diff --git a/tests/src/test/java/io/jooby/i3863/AbstractTrpcProtocolTest.java b/tests/src/test/java/io/jooby/i3863/AbstractTrpcProtocolTest.java index 71776fe793..3e1f5c8991 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 MovieServiceTrpc_()); + app.trpc(new MovieServiceTrpc_()); } @ServerTest From cf406e4f5f39b5a7ea1c0ff322684880d701b986 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Tue, 31 Mar 2026 10:57:59 -0300 Subject: [PATCH 54/56] - build: add breaking changes to release notes --- .github/workflows/maven-central.yml | 33 ++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/.github/workflows/maven-central.yml b/.github/workflows/maven-central.yml index 3296cae60c..8b9af8dbf9 100644 --- a/.github/workflows/maven-central.yml +++ b/.github/workflows/maven-central.yml @@ -72,28 +72,45 @@ jobs: # Fetch milestone ID for the link MILESTONE_ID=$(gh api repos/${{ github.repository }}/milestones -q ".[] | select(.title==\"$VERSION_NUM\") | .number") - # 1. Fetch NEW features (requires the 'feature' label) + # 1. Fetch BREAKING changes (requires the 'break-change' label) gh issue list \ --repo ${{ github.repository }} \ - --search "milestone:\"$VERSION_NUM\" label:feature" \ + --search "milestone:\"$VERSION_NUM\" label:break-change" \ + --state all \ + --limit 100 \ + --json title,url \ + --jq '.[] | "- [\(.title)](\(.url))"' > breaking_issues.md + + # 2. Fetch NEW features (requires the 'feature' label) + gh issue list \ + --repo ${{ github.repository }} \ + --search "milestone:\"$VERSION_NUM\" label:feature -label:break-change" \ --state all \ --limit 100 \ --json title,url \ --jq '.[] | "- [\(.title)](\(.url))"' > new_issues.md - # 2. Fetch OTHER changes (excludes 'feature' and 'dependencies') + # 3. Fetch OTHER changes (excludes 'feature', 'break-change', and 'dependencies') gh issue list \ --repo ${{ github.repository }} \ - --search "milestone:\"$VERSION_NUM\" -label:feature -label:dependencies" \ + --search "milestone:\"$VERSION_NUM\" -label:feature -label:break-change -label:dependencies" \ --state all \ --limit 100 \ --json title,url \ --jq '.[] | "- [\(.title)](\(.url))"' > other_issues.md - # 3. Initialize the changelog file + # 4. Initialize the changelog file > changelog.md - # 4. Conditionally add "What's New" if new_issues.md has content (-s) + # 5. Conditionally add "Breaking Changes" if breaking_issues.md has content (-s) + if [ -s breaking_issues.md ]; then + echo "## ⚠️ Breaking Changes" >> changelog.md + echo "" >> changelog.md + cat breaking_issues.md >> changelog.md + echo "" >> changelog.md + fi + + # 6. Conditionally add "What's New" if new_issues.md has content if [ -s new_issues.md ]; then echo "## 🚀 What's New" >> changelog.md echo "" >> changelog.md @@ -101,7 +118,7 @@ jobs: echo "" >> changelog.md fi - # 5. Conditionally add "Other Changes" if other_issues.md has content + # 7. Conditionally add "Other Changes" if other_issues.md has content if [ -s other_issues.md ]; then echo "## 🛠️ Changes" >> changelog.md echo "" >> changelog.md @@ -109,7 +126,7 @@ jobs: echo "" >> changelog.md fi - # 6. Build the rest of the changelog (Links & Sponsors) + # 8. Build the rest of the changelog (Links & Sponsors) cat << EOF >> changelog.md ### 🔗 Links & Resources - [$CURRENT_TAG](https://github.com/jooby-project/jooby/tree/$CURRENT_TAG) From 35eee68bff165ee603af52253c8e848bc715adc4 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Tue, 31 Mar 2026 10:59:34 -0300 Subject: [PATCH 55/56] build: add random port to make windows OS happy --- .../src/test/java/io/jooby/test/AppPerTestUsingJoobyTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/jooby-test/src/test/java/io/jooby/test/AppPerTestUsingJoobyTest.java b/modules/jooby-test/src/test/java/io/jooby/test/AppPerTestUsingJoobyTest.java index de1a55f52b..b62ec135f3 100644 --- a/modules/jooby-test/src/test/java/io/jooby/test/AppPerTestUsingJoobyTest.java +++ b/modules/jooby-test/src/test/java/io/jooby/test/AppPerTestUsingJoobyTest.java @@ -45,7 +45,7 @@ public void shouldUseFactoryMethod(int serverPort) throws IOException { } } - @JoobyTest(value = TestArgApp.class, factoryMethod = "createStaticApp") + @JoobyTest(value = TestArgApp.class, factoryMethod = "createStaticApp", port = 0) public void shouldUseStaticFactoryMethod(int serverPort) throws IOException { Request request = new Request.Builder().url("http://localhost:" + serverPort + "/").build(); From 62ba6d1a7cfe9157302eef1ba92c2951b93a796d Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Tue, 31 Mar 2026 11:15:52 -0300 Subject: [PATCH 56/56] v4.2.0 --- jooby/pom.xml | 2 +- modules/jooby-apt/pom.xml | 2 +- modules/jooby-avaje-inject/pom.xml | 2 +- modules/jooby-avaje-jsonb/pom.xml | 2 +- modules/jooby-avaje-validator/pom.xml | 2 +- modules/jooby-awssdk-v1/pom.xml | 2 +- modules/jooby-awssdk-v2/pom.xml | 2 +- modules/jooby-bom/pom.xml | 4 ++-- modules/jooby-caffeine/pom.xml | 2 +- modules/jooby-camel/pom.xml | 2 +- modules/jooby-cli/pom.xml | 2 +- modules/jooby-commons-email/pom.xml | 2 +- modules/jooby-conscrypt/pom.xml | 2 +- modules/jooby-db-scheduler/pom.xml | 2 +- modules/jooby-distribution/pom.xml | 2 +- modules/jooby-ebean/pom.xml | 2 +- modules/jooby-flyway/pom.xml | 2 +- modules/jooby-freemarker/pom.xml | 2 +- modules/jooby-gradle-setup/pom.xml | 2 +- modules/jooby-graphiql/pom.xml | 2 +- modules/jooby-graphql/pom.xml | 2 +- modules/jooby-grpc/pom.xml | 2 +- modules/jooby-gson/pom.xml | 2 +- modules/jooby-guice/pom.xml | 2 +- modules/jooby-handlebars/pom.xml | 2 +- modules/jooby-hibernate-validator/pom.xml | 2 +- modules/jooby-hibernate/pom.xml | 2 +- modules/jooby-hikari/pom.xml | 2 +- modules/jooby-jackson/pom.xml | 2 +- modules/jooby-jackson3/pom.xml | 2 +- modules/jooby-jasypt/pom.xml | 2 +- modules/jooby-javadoc/pom.xml | 2 +- modules/jooby-jdbi/pom.xml | 2 +- modules/jooby-jetty/pom.xml | 2 +- modules/jooby-jstachio/pom.xml | 2 +- modules/jooby-jte/pom.xml | 2 +- modules/jooby-jwt/pom.xml | 2 +- modules/jooby-kafka/pom.xml | 2 +- modules/jooby-kotlin/pom.xml | 2 +- modules/jooby-langchain4j/pom.xml | 2 +- modules/jooby-log4j/pom.xml | 2 +- modules/jooby-logback/pom.xml | 2 +- modules/jooby-maven-plugin/pom.xml | 2 +- modules/jooby-mcp-jackson2/pom.xml | 2 +- modules/jooby-mcp-jackson3/pom.xml | 2 +- modules/jooby-mcp/pom.xml | 2 +- modules/jooby-metrics/pom.xml | 2 +- modules/jooby-mutiny/pom.xml | 2 +- modules/jooby-netty/pom.xml | 2 +- modules/jooby-openapi/pom.xml | 2 +- modules/jooby-pac4j/pom.xml | 2 +- modules/jooby-pebble/pom.xml | 2 +- modules/jooby-quartz/pom.xml | 2 +- modules/jooby-reactor/pom.xml | 2 +- modules/jooby-redis/pom.xml | 2 +- modules/jooby-redoc/pom.xml | 2 +- modules/jooby-rocker/pom.xml | 2 +- modules/jooby-run/pom.xml | 2 +- modules/jooby-rxjava3/pom.xml | 2 +- modules/jooby-stork/pom.xml | 2 +- modules/jooby-swagger-ui/pom.xml | 2 +- modules/jooby-test/pom.xml | 2 +- modules/jooby-thymeleaf/pom.xml | 2 +- modules/jooby-trpc/pom.xml | 2 +- modules/jooby-undertow/pom.xml | 2 +- modules/jooby-vertx-mysql-client/pom.xml | 2 +- modules/jooby-vertx-pg-client/pom.xml | 2 +- modules/jooby-vertx-sql-client/pom.xml | 2 +- modules/jooby-vertx/pom.xml | 2 +- modules/jooby-whoops/pom.xml | 2 +- modules/jooby-yasson/pom.xml | 2 +- modules/pom.xml | 2 +- pom.xml | 4 ++-- tests/pom.xml | 2 +- 74 files changed, 76 insertions(+), 76 deletions(-) diff --git a/jooby/pom.xml b/jooby/pom.xml index b9b8b634e7..c99be0e8ab 100644 --- a/jooby/pom.xml +++ b/jooby/pom.xml @@ -6,7 +6,7 @@ io.jooby jooby-project - 4.1.1-SNAPSHOT + 4.2.0 jooby jooby diff --git a/modules/jooby-apt/pom.xml b/modules/jooby-apt/pom.xml index 3346cff7f0..2674e96225 100644 --- a/modules/jooby-apt/pom.xml +++ b/modules/jooby-apt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-apt jooby-apt diff --git a/modules/jooby-avaje-inject/pom.xml b/modules/jooby-avaje-inject/pom.xml index 9ff786ea2d..d1ef683b9f 100644 --- a/modules/jooby-avaje-inject/pom.xml +++ b/modules/jooby-avaje-inject/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-avaje-inject jooby-avaje-inject diff --git a/modules/jooby-avaje-jsonb/pom.xml b/modules/jooby-avaje-jsonb/pom.xml index c6263c0262..595bd88f8d 100644 --- a/modules/jooby-avaje-jsonb/pom.xml +++ b/modules/jooby-avaje-jsonb/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-avaje-jsonb jooby-avaje-jsonb diff --git a/modules/jooby-avaje-validator/pom.xml b/modules/jooby-avaje-validator/pom.xml index 2c5c6a9c61..ad38cebccb 100644 --- a/modules/jooby-avaje-validator/pom.xml +++ b/modules/jooby-avaje-validator/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-avaje-validator jooby-avaje-validator diff --git a/modules/jooby-awssdk-v1/pom.xml b/modules/jooby-awssdk-v1/pom.xml index 51d67d9fc0..da13fc02dc 100644 --- a/modules/jooby-awssdk-v1/pom.xml +++ b/modules/jooby-awssdk-v1/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-awssdk-v1 jooby-awssdk-v1 diff --git a/modules/jooby-awssdk-v2/pom.xml b/modules/jooby-awssdk-v2/pom.xml index 98e578b601..d1c2c4563a 100644 --- a/modules/jooby-awssdk-v2/pom.xml +++ b/modules/jooby-awssdk-v2/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-awssdk-v2 jooby-awssdk-v2 diff --git a/modules/jooby-bom/pom.xml b/modules/jooby-bom/pom.xml index 6e1256a46a..e43a207deb 100644 --- a/modules/jooby-bom/pom.xml +++ b/modules/jooby-bom/pom.xml @@ -7,14 +7,14 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 io.jooby jooby-bom jooby-bom pom - 4.1.1-SNAPSHOT + 4.2.0 Jooby (Bill of Materials) https://jooby.io diff --git a/modules/jooby-caffeine/pom.xml b/modules/jooby-caffeine/pom.xml index 305674fc50..40c0d08a13 100644 --- a/modules/jooby-caffeine/pom.xml +++ b/modules/jooby-caffeine/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-caffeine jooby-caffeine diff --git a/modules/jooby-camel/pom.xml b/modules/jooby-camel/pom.xml index 5f0a9ed429..33fc55abea 100644 --- a/modules/jooby-camel/pom.xml +++ b/modules/jooby-camel/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-camel jooby-camel diff --git a/modules/jooby-cli/pom.xml b/modules/jooby-cli/pom.xml index 636364517d..769164aac1 100644 --- a/modules/jooby-cli/pom.xml +++ b/modules/jooby-cli/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-cli jooby-cli diff --git a/modules/jooby-commons-email/pom.xml b/modules/jooby-commons-email/pom.xml index de3f0a0d28..fe849c63f0 100644 --- a/modules/jooby-commons-email/pom.xml +++ b/modules/jooby-commons-email/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-commons-email jooby-commons-email diff --git a/modules/jooby-conscrypt/pom.xml b/modules/jooby-conscrypt/pom.xml index 3d2546ad48..24f6ac6955 100644 --- a/modules/jooby-conscrypt/pom.xml +++ b/modules/jooby-conscrypt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-conscrypt jooby-conscrypt diff --git a/modules/jooby-db-scheduler/pom.xml b/modules/jooby-db-scheduler/pom.xml index fb4bc5dc93..702d064a65 100644 --- a/modules/jooby-db-scheduler/pom.xml +++ b/modules/jooby-db-scheduler/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-db-scheduler jooby-db-scheduler diff --git a/modules/jooby-distribution/pom.xml b/modules/jooby-distribution/pom.xml index bb6b4d0070..3cef55da9a 100644 --- a/modules/jooby-distribution/pom.xml +++ b/modules/jooby-distribution/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-distribution jooby-distribution diff --git a/modules/jooby-ebean/pom.xml b/modules/jooby-ebean/pom.xml index 824949ccb9..175923f8ca 100644 --- a/modules/jooby-ebean/pom.xml +++ b/modules/jooby-ebean/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-ebean jooby-ebean diff --git a/modules/jooby-flyway/pom.xml b/modules/jooby-flyway/pom.xml index 8b7f666609..2bed2479c2 100644 --- a/modules/jooby-flyway/pom.xml +++ b/modules/jooby-flyway/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-flyway jooby-flyway diff --git a/modules/jooby-freemarker/pom.xml b/modules/jooby-freemarker/pom.xml index b0fc5278bf..a904013340 100644 --- a/modules/jooby-freemarker/pom.xml +++ b/modules/jooby-freemarker/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-freemarker jooby-freemarker diff --git a/modules/jooby-gradle-setup/pom.xml b/modules/jooby-gradle-setup/pom.xml index 68c0f9947b..d5a27aa174 100644 --- a/modules/jooby-gradle-setup/pom.xml +++ b/modules/jooby-gradle-setup/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-gradle-setup jooby-gradle-setup diff --git a/modules/jooby-graphiql/pom.xml b/modules/jooby-graphiql/pom.xml index 4f48357a73..41c7f092ae 100644 --- a/modules/jooby-graphiql/pom.xml +++ b/modules/jooby-graphiql/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-graphiql jooby-graphiql diff --git a/modules/jooby-graphql/pom.xml b/modules/jooby-graphql/pom.xml index 5e4c7fc3d9..b3bd8126c4 100644 --- a/modules/jooby-graphql/pom.xml +++ b/modules/jooby-graphql/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-graphql jooby-graphql diff --git a/modules/jooby-grpc/pom.xml b/modules/jooby-grpc/pom.xml index 1975109225..942b5fb842 100644 --- a/modules/jooby-grpc/pom.xml +++ b/modules/jooby-grpc/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-grpc jooby-grpc diff --git a/modules/jooby-gson/pom.xml b/modules/jooby-gson/pom.xml index 29b6287011..9c5ec923f9 100644 --- a/modules/jooby-gson/pom.xml +++ b/modules/jooby-gson/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-gson jooby-gson diff --git a/modules/jooby-guice/pom.xml b/modules/jooby-guice/pom.xml index 665bc60ec6..c30267bdaa 100644 --- a/modules/jooby-guice/pom.xml +++ b/modules/jooby-guice/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-guice jooby-guice diff --git a/modules/jooby-handlebars/pom.xml b/modules/jooby-handlebars/pom.xml index b651c3be0b..e11fc7ec02 100644 --- a/modules/jooby-handlebars/pom.xml +++ b/modules/jooby-handlebars/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-handlebars jooby-handlebars diff --git a/modules/jooby-hibernate-validator/pom.xml b/modules/jooby-hibernate-validator/pom.xml index 2413b5144d..322330f2ac 100644 --- a/modules/jooby-hibernate-validator/pom.xml +++ b/modules/jooby-hibernate-validator/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-hibernate-validator jooby-hibernate-validator diff --git a/modules/jooby-hibernate/pom.xml b/modules/jooby-hibernate/pom.xml index 72800a7a9c..8aa7be4727 100644 --- a/modules/jooby-hibernate/pom.xml +++ b/modules/jooby-hibernate/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-hibernate jooby-hibernate diff --git a/modules/jooby-hikari/pom.xml b/modules/jooby-hikari/pom.xml index 5a2473e002..f636505f72 100644 --- a/modules/jooby-hikari/pom.xml +++ b/modules/jooby-hikari/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-hikari jooby-hikari diff --git a/modules/jooby-jackson/pom.xml b/modules/jooby-jackson/pom.xml index 1020443f05..63e25177f5 100644 --- a/modules/jooby-jackson/pom.xml +++ b/modules/jooby-jackson/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-jackson jooby-jackson diff --git a/modules/jooby-jackson3/pom.xml b/modules/jooby-jackson3/pom.xml index 9fa39d10aa..5f3d755e58 100644 --- a/modules/jooby-jackson3/pom.xml +++ b/modules/jooby-jackson3/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-jackson3 jooby-jackson3 diff --git a/modules/jooby-jasypt/pom.xml b/modules/jooby-jasypt/pom.xml index fbb04fc0c9..d5656f160b 100644 --- a/modules/jooby-jasypt/pom.xml +++ b/modules/jooby-jasypt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-jasypt jooby-jasypt diff --git a/modules/jooby-javadoc/pom.xml b/modules/jooby-javadoc/pom.xml index 40dab9fc6e..a61504aa84 100644 --- a/modules/jooby-javadoc/pom.xml +++ b/modules/jooby-javadoc/pom.xml @@ -8,7 +8,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-javadoc jooby-javadoc diff --git a/modules/jooby-jdbi/pom.xml b/modules/jooby-jdbi/pom.xml index 41e2615cb8..76ecc0f8d2 100644 --- a/modules/jooby-jdbi/pom.xml +++ b/modules/jooby-jdbi/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-jdbi jooby-jdbi diff --git a/modules/jooby-jetty/pom.xml b/modules/jooby-jetty/pom.xml index 618847afce..d55c9a17af 100644 --- a/modules/jooby-jetty/pom.xml +++ b/modules/jooby-jetty/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-jetty jooby-jetty diff --git a/modules/jooby-jstachio/pom.xml b/modules/jooby-jstachio/pom.xml index a754c32dac..1320cc3f43 100644 --- a/modules/jooby-jstachio/pom.xml +++ b/modules/jooby-jstachio/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-jstachio jooby-jstachio diff --git a/modules/jooby-jte/pom.xml b/modules/jooby-jte/pom.xml index 77bfae90a7..96c2c66157 100644 --- a/modules/jooby-jte/pom.xml +++ b/modules/jooby-jte/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-jte jooby-jte diff --git a/modules/jooby-jwt/pom.xml b/modules/jooby-jwt/pom.xml index 94f1400edc..63319150cd 100644 --- a/modules/jooby-jwt/pom.xml +++ b/modules/jooby-jwt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-jwt jooby-jwt diff --git a/modules/jooby-kafka/pom.xml b/modules/jooby-kafka/pom.xml index dec63148fa..9db9552a42 100644 --- a/modules/jooby-kafka/pom.xml +++ b/modules/jooby-kafka/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-kafka jooby-kafka diff --git a/modules/jooby-kotlin/pom.xml b/modules/jooby-kotlin/pom.xml index bd1779be6c..7bacdee692 100644 --- a/modules/jooby-kotlin/pom.xml +++ b/modules/jooby-kotlin/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-kotlin jooby-kotlin diff --git a/modules/jooby-langchain4j/pom.xml b/modules/jooby-langchain4j/pom.xml index 8a10043c9c..817c167bb0 100644 --- a/modules/jooby-langchain4j/pom.xml +++ b/modules/jooby-langchain4j/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-langchain4j jooby-langchain4j diff --git a/modules/jooby-log4j/pom.xml b/modules/jooby-log4j/pom.xml index b701ee0653..b65af70076 100644 --- a/modules/jooby-log4j/pom.xml +++ b/modules/jooby-log4j/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-log4j jooby-log4j diff --git a/modules/jooby-logback/pom.xml b/modules/jooby-logback/pom.xml index c49d73add1..c0131f058e 100644 --- a/modules/jooby-logback/pom.xml +++ b/modules/jooby-logback/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-logback jooby-logback diff --git a/modules/jooby-maven-plugin/pom.xml b/modules/jooby-maven-plugin/pom.xml index 980b785d2f..188084c377 100644 --- a/modules/jooby-maven-plugin/pom.xml +++ b/modules/jooby-maven-plugin/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-maven-plugin jooby-maven-plugin diff --git a/modules/jooby-mcp-jackson2/pom.xml b/modules/jooby-mcp-jackson2/pom.xml index d097cd2b72..3df62cce83 100644 --- a/modules/jooby-mcp-jackson2/pom.xml +++ b/modules/jooby-mcp-jackson2/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-mcp-jackson2 diff --git a/modules/jooby-mcp-jackson3/pom.xml b/modules/jooby-mcp-jackson3/pom.xml index bab37e6c86..d3ca768983 100644 --- a/modules/jooby-mcp-jackson3/pom.xml +++ b/modules/jooby-mcp-jackson3/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-mcp-jackson3 diff --git a/modules/jooby-mcp/pom.xml b/modules/jooby-mcp/pom.xml index 36cb165790..3595b426cb 100644 --- a/modules/jooby-mcp/pom.xml +++ b/modules/jooby-mcp/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-mcp diff --git a/modules/jooby-metrics/pom.xml b/modules/jooby-metrics/pom.xml index c466c36fa7..39a97dc5e9 100644 --- a/modules/jooby-metrics/pom.xml +++ b/modules/jooby-metrics/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-metrics jooby-metrics diff --git a/modules/jooby-mutiny/pom.xml b/modules/jooby-mutiny/pom.xml index 4a283898cb..88bbc08db4 100644 --- a/modules/jooby-mutiny/pom.xml +++ b/modules/jooby-mutiny/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-mutiny jooby-mutiny diff --git a/modules/jooby-netty/pom.xml b/modules/jooby-netty/pom.xml index da9294a223..d9f07ea0f1 100644 --- a/modules/jooby-netty/pom.xml +++ b/modules/jooby-netty/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-netty jooby-netty diff --git a/modules/jooby-openapi/pom.xml b/modules/jooby-openapi/pom.xml index e79d2cfeb5..f6a7089a32 100644 --- a/modules/jooby-openapi/pom.xml +++ b/modules/jooby-openapi/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-openapi jooby-openapi diff --git a/modules/jooby-pac4j/pom.xml b/modules/jooby-pac4j/pom.xml index 08a6a1359f..3ba6a22370 100644 --- a/modules/jooby-pac4j/pom.xml +++ b/modules/jooby-pac4j/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-pac4j jooby-pac4j diff --git a/modules/jooby-pebble/pom.xml b/modules/jooby-pebble/pom.xml index 96620ec88b..223a61ff67 100644 --- a/modules/jooby-pebble/pom.xml +++ b/modules/jooby-pebble/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-pebble jooby-pebble diff --git a/modules/jooby-quartz/pom.xml b/modules/jooby-quartz/pom.xml index 5b68342acd..8d7e331905 100644 --- a/modules/jooby-quartz/pom.xml +++ b/modules/jooby-quartz/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-quartz jooby-quartz diff --git a/modules/jooby-reactor/pom.xml b/modules/jooby-reactor/pom.xml index 7a5bc99cde..2eab1c68d9 100644 --- a/modules/jooby-reactor/pom.xml +++ b/modules/jooby-reactor/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-reactor jooby-reactor diff --git a/modules/jooby-redis/pom.xml b/modules/jooby-redis/pom.xml index ecee4f118b..5ff8431bf7 100644 --- a/modules/jooby-redis/pom.xml +++ b/modules/jooby-redis/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-redis jooby-redis diff --git a/modules/jooby-redoc/pom.xml b/modules/jooby-redoc/pom.xml index fe324490e0..8856cf0a76 100644 --- a/modules/jooby-redoc/pom.xml +++ b/modules/jooby-redoc/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-redoc jooby-redoc diff --git a/modules/jooby-rocker/pom.xml b/modules/jooby-rocker/pom.xml index 502791f3e5..4a9af3083c 100644 --- a/modules/jooby-rocker/pom.xml +++ b/modules/jooby-rocker/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-rocker jooby-rocker diff --git a/modules/jooby-run/pom.xml b/modules/jooby-run/pom.xml index bc90d398a6..5ce9673294 100644 --- a/modules/jooby-run/pom.xml +++ b/modules/jooby-run/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-run jooby-run diff --git a/modules/jooby-rxjava3/pom.xml b/modules/jooby-rxjava3/pom.xml index 7718a2d81d..791d362709 100644 --- a/modules/jooby-rxjava3/pom.xml +++ b/modules/jooby-rxjava3/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-rxjava3 jooby-rxjava3 diff --git a/modules/jooby-stork/pom.xml b/modules/jooby-stork/pom.xml index 25eb5fb2cd..b267434616 100644 --- a/modules/jooby-stork/pom.xml +++ b/modules/jooby-stork/pom.xml @@ -4,7 +4,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-stork diff --git a/modules/jooby-swagger-ui/pom.xml b/modules/jooby-swagger-ui/pom.xml index 3cf88a076f..f21f0d0ca9 100644 --- a/modules/jooby-swagger-ui/pom.xml +++ b/modules/jooby-swagger-ui/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-swagger-ui jooby-swagger-ui diff --git a/modules/jooby-test/pom.xml b/modules/jooby-test/pom.xml index 14c5a2fb4a..1dd5bfcea0 100644 --- a/modules/jooby-test/pom.xml +++ b/modules/jooby-test/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-test jooby-test diff --git a/modules/jooby-thymeleaf/pom.xml b/modules/jooby-thymeleaf/pom.xml index fcb3241202..d86cb9e820 100644 --- a/modules/jooby-thymeleaf/pom.xml +++ b/modules/jooby-thymeleaf/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-thymeleaf jooby-thymeleaf diff --git a/modules/jooby-trpc/pom.xml b/modules/jooby-trpc/pom.xml index 0a02bd412d..f98b3b7222 100644 --- a/modules/jooby-trpc/pom.xml +++ b/modules/jooby-trpc/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-trpc jooby-trpc diff --git a/modules/jooby-undertow/pom.xml b/modules/jooby-undertow/pom.xml index da6b0b7998..9d2d1b7bf5 100644 --- a/modules/jooby-undertow/pom.xml +++ b/modules/jooby-undertow/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-undertow jooby-undertow diff --git a/modules/jooby-vertx-mysql-client/pom.xml b/modules/jooby-vertx-mysql-client/pom.xml index 73b7fceb7f..85fb616dd5 100644 --- a/modules/jooby-vertx-mysql-client/pom.xml +++ b/modules/jooby-vertx-mysql-client/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-vertx-mysql-client jooby-vertx-mysql-client diff --git a/modules/jooby-vertx-pg-client/pom.xml b/modules/jooby-vertx-pg-client/pom.xml index 889beec48c..830464c8d3 100644 --- a/modules/jooby-vertx-pg-client/pom.xml +++ b/modules/jooby-vertx-pg-client/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-vertx-pg-client jooby-vertx-pg-client diff --git a/modules/jooby-vertx-sql-client/pom.xml b/modules/jooby-vertx-sql-client/pom.xml index 4138c53464..9ed175bec6 100644 --- a/modules/jooby-vertx-sql-client/pom.xml +++ b/modules/jooby-vertx-sql-client/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-vertx-sql-client jooby-vertx-sql-client diff --git a/modules/jooby-vertx/pom.xml b/modules/jooby-vertx/pom.xml index eb9cb1f912..de8ff4d65b 100644 --- a/modules/jooby-vertx/pom.xml +++ b/modules/jooby-vertx/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-vertx jooby-vertx diff --git a/modules/jooby-whoops/pom.xml b/modules/jooby-whoops/pom.xml index fbe60130e5..50dd0d1e9c 100644 --- a/modules/jooby-whoops/pom.xml +++ b/modules/jooby-whoops/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-whoops jooby-whoops diff --git a/modules/jooby-yasson/pom.xml b/modules/jooby-yasson/pom.xml index be4bd6cc32..7d52a6d236 100644 --- a/modules/jooby-yasson/pom.xml +++ b/modules/jooby-yasson/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.1.1-SNAPSHOT + 4.2.0 jooby-yasson jooby-yasson diff --git a/modules/pom.xml b/modules/pom.xml index 51c3e41ea9..8dc080e306 100644 --- a/modules/pom.xml +++ b/modules/pom.xml @@ -4,7 +4,7 @@ io.jooby jooby-project - 4.1.1-SNAPSHOT + 4.2.0 modules diff --git a/pom.xml b/pom.xml index 91ce9a5cc7..c1ecd90c78 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.jooby jooby-project - 4.1.1-SNAPSHOT + 4.2.0 pom jooby-project @@ -212,7 +212,7 @@ 21 21 yyyy-MM-dd HH:mm:ssa - 2026-03-16T15:44:45Z + 2026-03-31T14:15:44Z UTF-8 etc${file.separator}source${file.separator}formatter.sh diff --git a/tests/pom.xml b/tests/pom.xml index 1bc79f30e3..128cf7c0ff 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -6,7 +6,7 @@ io.jooby jooby-project - 4.1.1-SNAPSHOT + 4.2.0 tests tests