diff --git a/docs/asciidoc/modules/mcp.adoc b/docs/asciidoc/modules/mcp.adoc index d63589193a..1762281545 100644 --- a/docs/asciidoc/modules/mcp.adoc +++ b/docs/asciidoc/modules/mcp.adoc @@ -189,7 +189,7 @@ public class UserService { <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 +=== Custom Invokers 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`. @@ -204,16 +204,21 @@ Invokers are chained. You can register multiple invokers and they will wrap the ---- import io.jooby.mcp.McpInvoker; import io.jooby.mcp.McpOperation; +import io.jooby.mcp.McpChain; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import org.jspecify.annotations.Nullable; import org.slf4j.MDC; public class MdcMcpInvoker implements McpInvoker { @Override - public R invoke(McpOperation operation, SneakyThrows.Supplier action) { + public R invoke(@Nullable McpSyncServerExchange exchange, McpTransportContext transportContext, McpOperation operation, McpChain chain) throws Exception { try { - MDC.put("mcp.id", operation.id()); <1> + MDC.put("mcp.id", operation.id()); // <1> MDC.put("mcp.class", operation.className()); MDC.put("mcp.method", operation.methodName()); - return action.get(); <2> + + return chain.proceed(exchange, transportContext, operation); // <2> } finally { MDC.remove("mcp.id"); MDC.remove("mcp.class"); @@ -224,13 +229,66 @@ public class MdcMcpInvoker implements McpInvoker { { install(new McpModule(new CalculatorServiceMcp_()) - .invoker(new MdcMcpInvoker())); <3> + .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. +<2> Proceed to the next interceptor in the chain or execute the final target handler. +<3> Register the invoker. Jooby will safely map any business exceptions thrown by your chain into valid MCP JSON-RPC errors. + +==== Context Augmentation + +You can use an `McpInvoker` to resolve contextual data (such as an authenticated user, a tenant ID, etc.) and inject it directly into the `McpOperation`. + +This allows your tool methods to simply declare the custom type in their method signature, keeping your business logic clean and completely decoupled from transport-layer extraction. + +.Context Injector Example +[source, java] +---- +import io.jooby.mcp.McpInvoker; +import io.jooby.mcp.McpOperation; +import io.jooby.mcp.McpChain; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import org.jspecify.annotations.Nullable; + +public class UserContextInvoker implements McpInvoker { + @Override + @SuppressWarnings("unchecked") + public R invoke(@Nullable McpSyncServerExchange exchange, McpTransportContext transportContext, McpOperation operation, McpChain chain) throws Exception { + + User currentUser = retrieveUser(); + + // 2. Augment the operation with the resolved user + operation.setArgument("user", currentUser); + + // 3. Proceed with the augmented operation + return (R) chain.proceed(exchange, transportContext, augmentedOp); + } +} +---- + +Once the invoker is registered, you can seamlessly declare the augmented argument in your MCP controllers. The Jooby Annotation Processor will automatically map the injected argument to your method parameter. + +.Tool Implementation +[source, java] +---- +import io.jooby.annotation.mcp.McpTool; + +public class BillingService { + + /** + * @param user The authenticated user (injected by UserContextInvoker). + * Note: Because it is a complex type not present in the JSON request, + * it is safely ignored by the JSON schema generator. + */ + @McpTool(description = "Retrieves the billing history for the current user") + public InvoiceHistory getMyInvoices(User user, int limit) { + return database.findInvoices(user.getId(), limit); + } +} +---- === Multiple Servers 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 76576ca5b9..c380a2c0ed 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 @@ -24,7 +24,7 @@ import javax.tools.*; import io.jooby.internal.apt.*; - +import io.jooby.internal.apt.mcp.McpRouter; import io.jooby.internal.apt.ws.WsRouter; /** Process jooby/jakarta annotation and generate source code from MVC controllers. */ 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/mcp/McpRoute.java similarity index 96% rename from modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java rename to modules/jooby-apt/src/main/java/io/jooby/internal/apt/mcp/McpRoute.java index 1fc1e0b963..6d99dfa679 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/mcp/McpRoute.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.internal.apt; +package io.jooby.internal.apt.mcp; import static io.jooby.internal.apt.CodeBlock.*; import static io.jooby.internal.apt.CodeBlock.string; @@ -15,6 +15,8 @@ import javax.lang.model.element.ExecutableElement; +import io.jooby.internal.apt.AnnotationSupport; +import io.jooby.internal.apt.WebRoute; import io.jooby.javadoc.JavaDocNode; import io.jooby.javadoc.MethodDoc; @@ -333,7 +335,8 @@ private List generatePromptDefinition(boolean kt) { 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; + || type.equals("io.jooby.Context") + || type.equals("io.jooby.mcp.McpOperation")) continue; var mcpName = param.getMcpName(); var isRequired = !param.isNullable(kt); @@ -461,7 +464,8 @@ private List generateToolDefinition(boolean kt) { 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; + || type.equals("io.jooby.Context") + || type.equals("io.jooby.mcp.McpOperation")) continue; var mcpName = param.getMcpName(); var paramDescription = param.getMcpDescription(); @@ -809,15 +813,19 @@ public List generateMcpHandlerMethod(boolean kt) { "private fun ", handlerName, "(exchange: io.modelcontextprotocol.server.McpSyncServerExchange?, transportContext:" - + " io.modelcontextprotocol.common.McpTransportContext, req:" - + " io.modelcontextprotocol.spec.McpSchema.", - reqType, - "): io.modelcontextprotocol.spec.McpSchema.", + + " io.modelcontextprotocol.common.McpTransportContext, operation:" + + " io.jooby.mcp.McpOperation): io.modelcontextprotocol.spec.McpSchema.", resType, " {")); buffer.add( statement(indent(6), "val ctx = transportContext.get(\"CTX\") as? io.jooby.Context")); + buffer.add( + statement( + indent(6), + "val req_ = operation.request(io.modelcontextprotocol.spec.McpSchema.", + reqType, + "::class.java)")); } else { buffer.add( statement( @@ -827,29 +835,28 @@ public List generateMcpHandlerMethod(boolean kt) { " ", handlerName, "(io.modelcontextprotocol.server.McpSyncServerExchange exchange," - + " io.modelcontextprotocol.common.McpTransportContext" - + " transportContext," - + " io.modelcontextprotocol.spec.McpSchema.", - reqType, - " req) {")); + + " io.modelcontextprotocol.common.McpTransportContext transportContext," + + " io.jooby.mcp.McpOperation operation) {")); buffer.add( statement( indent(6), "var ctx = (io.jooby.Context) transportContext.get(\"CTX\")", semicolon(kt))); + buffer.add( + statement( + indent(6), + "var req_ = operation.request(io.modelcontextprotocol.spec.McpSchema.", + reqType, + ".class)", + semicolon(kt))); } if (isMcpTool() || isMcpPrompt()) { if (kt) { - buffer.add(statement(indent(6), "val args = req.arguments() ?: emptyMap()")); + buffer.add(statement(indent(6), "val args = operation.arguments()")); } else { - buffer.add( - statement( - indent(6), - "var args = req.arguments() != null ? req.arguments() :" - + " java.util.Collections.emptyMap()", - semicolon(kt))); + buffer.add(statement(indent(6), "var args = operation.arguments()", semicolon(kt))); } } else if (isMcpResource() || isMcpResourceTemplate()) { String uriTemplate = extractAnnotationValue("io.jooby.annotation.mcp.McpResource", "uri"); @@ -857,7 +864,7 @@ public List generateMcpHandlerMethod(boolean kt) { if (isTemplate) { if (kt) { - buffer.add(statement(indent(6), "val uri = req.uri()")); + buffer.add(statement(indent(6), "val uri = req_.uri()")); buffer.add( statement( indent(6), @@ -867,7 +874,7 @@ public List generateMcpHandlerMethod(boolean kt) { 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 uri = req_.uri()", semicolon(kt))); buffer.add( statement( indent(6), @@ -911,7 +918,11 @@ public List generateMcpHandlerMethod(boolean kt) { || 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))); + buffer.add(statement(indent(6), kt ? "val " : "var ", javaName, " = req_", semicolon(kt))); + continue; + } else if (type.equals("io.jooby.mcp.McpOperation")) { + buffer.add( + statement(indent(6), kt ? "val " : "var ", javaName, " = operation", semicolon(kt))); continue; } @@ -1070,7 +1081,7 @@ public List generateMcpHandlerMethod(boolean kt) { var methodCall = "c." + getMethodName() + "(" + String.join(", ", javaParamNames) + ")"; - String toMethodPrefix = (isMcpResource() || isMcpResourceTemplate()) ? "req.uri(), " : ""; + String toMethodPrefix = (isMcpResource() || isMcpResourceTemplate()) ? "req_.uri(), " : ""; // Resolve output schema flag for Handler runtime behavior String toMethodSuffix = ""; 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/mcp/McpRouter.java similarity index 88% rename from modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRouter.java rename to modules/jooby-apt/src/main/java/io/jooby/internal/apt/mcp/McpRouter.java index 21da72b576..54c14684fa 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/mcp/McpRouter.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.internal.apt; +package io.jooby.internal.apt.mcp; import static io.jooby.internal.apt.AnnotationSupport.VALUE; import static io.jooby.internal.apt.CodeBlock.*; @@ -23,6 +23,9 @@ import javax.lang.model.element.ExecutableElement; import javax.lang.model.element.TypeElement; +import io.jooby.internal.apt.AnnotationSupport; +import io.jooby.internal.apt.MvcContext; +import io.jooby.internal.apt.WebRouter; import io.jooby.javadoc.JavaDocParser; import io.jooby.javadoc.MethodDoc; @@ -347,38 +350,30 @@ private void appendCompletions( ? "io.modelcontextprotocol.spec.McpSchema.ResourceReference" : "io.modelcontextprotocol.spec.McpSchema.PromptReference"; - String lambda; + String invokerCall; if (groups.containsKey(ref)) { var targetMethod = findTargetMethodName(ref); var handlerName = targetMethod + "CompletionHandler"; - var operationArg = generateOperationArg(kt, "completions/" + ref, targetMethod); - - String invokeArgs = - isStateless ? "null, ctx, req" : "exchange, exchange.transportContext(), req"; - String lambdaArgs = isStateless ? "ctx, req" : "exchange, req"; - - lambda = - kt - ? "{ " - + lambdaArgs - + " -> invoker.invoke(" - + operationArg - + ") { this." - + handlerName - + "(" - + invokeArgs - + ") } }" - : "(" - + lambdaArgs - + ") -> invoker.invoke(" - + operationArg - + ", () -> this." - + handlerName - + "(" - + invokeArgs - + "))"; + var targetClass = getTargetType().toString(); + + String adapterMethod = isStateless ? "asStatelessCompletionHandler" : "asCompletionHandler"; + String handlerRef = "this::" + handlerName; + String operationId = "completions/" + ref; + + invokerCall = + "invoker." + + adapterMethod + + "(" + + string(operationId) + + ", " + + string(targetClass) + + ", " + + string(targetMethod) + + ", " + + handlerRef + + ")"; } else { - lambda = + invokerCall = kt ? "{ _, _ -> io.jooby.mcp.McpResult(this.json).toCompleteResult(emptyList()) }" : "(exchange, req) -> new" @@ -398,7 +393,7 @@ private void appendCompletions( "(", string(ref), "), ", - lambda, + invokerCall, "))")); } else { buffer.append( @@ -411,7 +406,7 @@ private void appendCompletions( "(", string(ref), "), ", - lambda, + invokerCall, "))", semicolon(kt))); } @@ -482,32 +477,30 @@ private void appendInstall( 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 adapterMethod = ""; + if (route.isMcpTool()) + adapterMethod = isStateless ? "asStatelessToolHandler" : "asToolHandler"; + else if (route.isMcpPrompt()) + adapterMethod = isStateless ? "asStatelessPromptHandler" : "asPromptHandler"; + else if (route.isMcpResource() || route.isMcpResourceTemplate()) + adapterMethod = isStateless ? "asStatelessResourceHandler" : "asResourceHandler"; + + String handlerRef = "this::" + methodName; + String targetClass = getTargetType().toString(); + String operationId = mcpType + "/" + mcpName; + String invokerCall = + of( + "invoker.", + adapterMethod, + "(", + string(operationId), + ",", + string(targetClass), + ", ", + string(methodName), + ", ", + handlerRef, + ")"); String prefix = kt ? "" : "new "; String serverMethod = "io.modelcontextprotocol.server." + featuresClass + "."; @@ -522,7 +515,7 @@ private void appendInstall( "SyncToolSpecification(", methodName, "ToolSpec(schemaGenerator), ", - lambda, + invokerCall, "))", semicolon(kt))); } else if (route.isMcpPrompt()) { @@ -535,7 +528,7 @@ private void appendInstall( "SyncPromptSpecification(", methodName, "PromptSpec(), ", - lambda, + invokerCall, "))", semicolon(kt))); } else if (route.isMcpResource() || route.isMcpResourceTemplate()) { @@ -556,7 +549,7 @@ private void appendInstall( methodName, defMethod, ", ", - lambda, + invokerCall, "))", semicolon(kt))); } @@ -577,14 +570,19 @@ private void appendCompletionHandlers( "private fun ", handlerName, "(exchange: io.modelcontextprotocol.server.McpSyncServerExchange?," - + " transportContext: io.modelcontextprotocol.common.McpTransportContext, req:" - + " io.modelcontextprotocol.spec.McpSchema.CompleteRequest):" + + " transportContext: io.modelcontextprotocol.common.McpTransportContext," + + " operation: io.jooby.mcp.McpOperation):" + " io.modelcontextprotocol.spec.McpSchema.CompleteResult {")); buffer.append( - statement(indent(6), "val ctx = transportContext.get(\"CTX\") as io.jooby.Context")); + statement( + indent(6), + "val req_ =" + + " operation.request(io.modelcontextprotocol.spec.McpSchema.CompleteRequest::class.java)")); + buffer.append( + statement(indent(6), "val ctx = transportContext.get(\"CTX\") as? io.jooby.Context")); buffer.append(statement(indent(6), "val c = this.factory.apply(ctx)")); - buffer.append(statement(indent(6), "val targetArg = req.argument()?.name() ?: \"\"")); - buffer.append(statement(indent(6), "val typedValue = req.argument()?.value() ?: \"\"")); + buffer.append(statement(indent(6), "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( @@ -594,7 +592,13 @@ private void appendCompletionHandlers( handlerName, "(io.modelcontextprotocol.server.McpSyncServerExchange exchange," + " io.modelcontextprotocol.common.McpTransportContext transportContext," - + " io.modelcontextprotocol.spec.McpSchema.CompleteRequest req) {")); + + " io.jooby.mcp.McpOperation operation) {")); + buffer.append( + statement( + indent(6), + "var req_ =" + + " operation.request(io.modelcontextprotocol.spec.McpSchema.CompleteRequest.class)", + semicolon(kt))); buffer.append( statement( indent(6), @@ -604,12 +608,12 @@ private void appendCompletionHandlers( buffer.append( statement( indent(6), - "var targetArg = req.argument() != null ? req.argument().name() : \"\"", + "var targetArg = req_.argument() != null ? req_.argument().name() : \"\"", semicolon(kt))); buffer.append( statement( indent(6), - "var typedValue = req.argument() != null ? req.argument().value() : \"\"", + "var typedValue = req_.argument() != null ? req_.argument().value() : \"\"", semicolon(kt))); buffer.append(statement(indent(6), "return switch (targetArg) {")); } @@ -625,6 +629,7 @@ else if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange")) invokeArgs.add("exchange"); else if (type.equals("io.modelcontextprotocol.common.McpTransportContext")) invokeArgs.add("transportContext"); + else if (type.equals("io.jooby.mcp.McpOperation")) invokeArgs.add("operation"); else { targetArgName = param.getMcpName(); invokeArgs.add("typedValue"); @@ -686,18 +691,6 @@ else if (type.equals("io.modelcontextprotocol.common.McpTransportContext")) } } - 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) { return javadoc.parse(getTargetType().toString()).flatMap(it -> it.getMethod(methodName, types)); } 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 339f535746..588a2b6f51 100644 --- a/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java +++ b/modules/jooby-apt/src/test/java/tests/i3830/Issue3830.java @@ -71,8 +71,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(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)))); + completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), invoker.asCompletionHandler("completions/review_code", "tests.i3830.ExampleServer", "reviewCode", this::reviewCodeCompletionHandler))); + completions.add(new io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.ResourceReference("file:///users/{id}/{name}/profile"), invoker.asCompletionHandler("completions/file:///users/{id}/{name}/profile", "tests.i3830.ExampleServer", "getUserProfile", this::getUserProfileCompletionHandler))); return completions; } @@ -80,8 +80,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(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)))); + completions.add(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.PromptReference("review_code"), invoker.asStatelessCompletionHandler("completions/review_code", "tests.i3830.ExampleServer", "reviewCode", this::reviewCodeCompletionHandler))); + completions.add(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification(new io.modelcontextprotocol.spec.McpSchema.ResourceReference("file:///users/{id}/{name}/profile"), invoker.asStatelessCompletionHandler("completions/file:///users/{id}/{name}/profile", "tests.i3830.ExampleServer", "getUserProfile", this::getUserProfileCompletionHandler))); return completions; } @@ -91,10 +91,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(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)))); + server.addTool(new io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification(addToolSpec(schemaGenerator), invoker.asToolHandler("tools/calculator","tests.i3830.ExampleServer", "add", this::add))); + server.addPrompt(new io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification(reviewCodePromptSpec(), invoker.asPromptHandler("prompts/review_code","tests.i3830.ExampleServer", "reviewCode", this::reviewCode))); + server.addResource(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification(getLogsResourceSpec(), invoker.asResourceHandler("resources/file:///logs/app.log","tests.i3830.ExampleServer", "getLogs", this::getLogs))); + server.addResourceTemplate(new io.modelcontextprotocol.server.McpServerFeatures.SyncResourceTemplateSpecification(getUserProfileResourceTemplateSpec(), invoker.asResourceHandler("resources/file:///users/{id}/{name}/profile","tests.i3830.ExampleServer", "getUserProfile", this::getUserProfile))); } @Override @@ -103,10 +103,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(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)))); + server.addTool(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncToolSpecification(addToolSpec(schemaGenerator), invoker.asStatelessToolHandler("tools/calculator","tests.i3830.ExampleServer", "add", this::add))); + server.addPrompt(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncPromptSpecification(reviewCodePromptSpec(), invoker.asStatelessPromptHandler("prompts/review_code","tests.i3830.ExampleServer", "reviewCode", this::reviewCode))); + server.addResource(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceSpecification(getLogsResourceSpec(), invoker.asStatelessResourceHandler("resources/file:///logs/app.log","tests.i3830.ExampleServer", "getLogs", this::getLogs))); + server.addResourceTemplate(new io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceTemplateSpecification(getUserProfileResourceTemplateSpec(), invoker.asStatelessResourceHandler("resources/file:///users/{id}/{name}/profile","tests.i3830.ExampleServer", "getUserProfile", this::getUserProfile))); } private io.modelcontextprotocol.spec.McpSchema.Tool addToolSpec(com.github.victools.jsonschema.generator.SchemaGenerator schemaGenerator) { @@ -128,9 +128,10 @@ private io.modelcontextprotocol.spec.McpSchema.Tool addToolSpec(com.github.victo 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) { + private io.modelcontextprotocol.spec.McpSchema.CallToolResult add(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.jooby.mcp.McpOperation operation) { var ctx = (io.jooby.Context) transportContext.get("CTX"); - var args = req.arguments() != null ? req.arguments() : java.util.Collections.emptyMap(); + var req_ = operation.request(io.modelcontextprotocol.spec.McpSchema.CallToolRequest.class); + var args = operation.arguments(); var c = this.factory.apply(ctx); var raw_a = args.get("a"); if (raw_a == null) throw new IllegalArgumentException("Missing req param: a"); @@ -149,9 +150,10 @@ private io.modelcontextprotocol.spec.McpSchema.Prompt reviewCodePromptSpec() { 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) { + private io.modelcontextprotocol.spec.McpSchema.GetPromptResult reviewCode(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.jooby.mcp.McpOperation operation) { var ctx = (io.jooby.Context) transportContext.get("CTX"); - var args = req.arguments() != null ? req.arguments() : java.util.Collections.emptyMap(); + var req_ = operation.request(io.modelcontextprotocol.spec.McpSchema.GetPromptRequest.class); + var args = operation.arguments(); var c = this.factory.apply(ctx); var raw_language = args.get("language"); var language = raw_language != null ? raw_language.toString() : null; @@ -167,21 +169,23 @@ private io.modelcontextprotocol.spec.McpSchema.Resource getLogsResourceSpec() { 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) { + private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getLogs(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.jooby.mcp.McpOperation operation) { var ctx = (io.jooby.Context) transportContext.get("CTX"); + var req_ = operation.request(io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest.class); 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); + 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) { + private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getUserProfile(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.jooby.mcp.McpOperation operation) { var ctx = (io.jooby.Context) transportContext.get("CTX"); - var uri = req.uri(); + var req_ = operation.request(io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest.class); + 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)); @@ -191,14 +195,15 @@ private io.modelcontextprotocol.spec.McpSchema.ReadResourceResult getUserProfile 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); + 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) { + private io.modelcontextprotocol.spec.McpSchema.CompleteResult getUserProfileCompletionHandler(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.jooby.mcp.McpOperation operation) { + var req_ = operation.request(io.modelcontextprotocol.spec.McpSchema.CompleteRequest.class); 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() : ""; + 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); @@ -212,11 +217,12 @@ 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) { + private io.modelcontextprotocol.spec.McpSchema.CompleteResult reviewCodeCompletionHandler(io.modelcontextprotocol.server.McpSyncServerExchange exchange, io.modelcontextprotocol.common.McpTransportContext transportContext, io.jooby.mcp.McpOperation operation) { + var req_ = operation.request(io.modelcontextprotocol.spec.McpSchema.CompleteRequest.class); 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() : ""; + 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); diff --git a/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/instrumentation/OtelJsonRcpTracing.java b/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/instrumentation/OtelJsonRcpTracing.java index 5805d664d6..5e1c1fa3e0 100644 --- a/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/instrumentation/OtelJsonRcpTracing.java +++ b/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/instrumentation/OtelJsonRcpTracing.java @@ -99,7 +99,7 @@ public OtelJsonRcpTracing onEnd(SneakyThrows.Consumer3 invoke( - @NonNull Context ctx, @NonNull JsonRpcRequest request, JsonRpcChain chain) { + @NonNull Context ctx, @NonNull JsonRpcRequest request, @NonNull JsonRpcChain chain) { var method = Optional.ofNullable(request.getMethod()).orElse("unknown_method"); var span = tracer 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/McpDefaultInvoker.java similarity index 64% rename from modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/DefaultMcpInvoker.java rename to modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpDefaultInvoker.java index 608924522d..39177e2a64 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/McpDefaultInvoker.java @@ -6,43 +6,51 @@ package io.jooby.internal.mcp; import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import org.slf4j.LoggerFactory; import io.jooby.Jooby; -import io.jooby.SneakyThrows; import io.jooby.StatusCode; +import io.jooby.mcp.McpChain; import io.jooby.mcp.McpInvoker; import io.jooby.mcp.McpOperation; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; -public class DefaultMcpInvoker implements McpInvoker { +public class McpDefaultInvoker implements McpInvoker { private final Jooby application; - public DefaultMcpInvoker(Jooby application) { + public McpDefaultInvoker(Jooby application) { this.application = application; } @SuppressWarnings("unchecked") - @Override - public R invoke(@NonNull McpOperation operation, SneakyThrows.@NonNull Supplier action) { + public @NonNull Object invoke( + @Nullable McpSyncServerExchange exchange, + @NonNull McpTransportContext transportContext, + @NonNull McpOperation operation, + @NonNull McpChain next) { try { - return action.get(); + return next.proceed(exchange, transportContext, operation); } catch (McpError mcpError) { throw mcpError; } catch (Throwable cause) { - var log = LoggerFactory.getLogger(operation.className()); - if (operation.id().startsWith("tools/")) { + var log = LoggerFactory.getLogger(operation.getClassName()); + if (operation.isTool()) { // Tool error var errorMessage = cause.getMessage() != null ? cause.getMessage() : cause.toString(); - return (R) - McpSchema.CallToolResult.builder().addTextContent(errorMessage).isError(true).build(); + return 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); + log.error("execution of {} resulted in exception", operation.getId(), cause); } else { - log.debug("execution of {} resulted in exception", operation.id(), cause); + log.debug("execution of {} resulted in exception", operation.getId(), cause); } var mcpErrorCode = toMcpErrorCode(statusCode); throw new McpError( diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpChain.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpChain.java new file mode 100644 index 0000000000..1c4dc94589 --- /dev/null +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpChain.java @@ -0,0 +1,43 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.mcp; + +import org.jspecify.annotations.Nullable; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpSyncServerExchange; + +/** + * Represents a chain of interceptors for an MCP operation. + * + *

When an MCP operation is executed, it passes through a chain of {@link McpInvoker} instances. + * The {@code McpChain} is responsible for yielding control to the next invoker in the chain, or + * finally executing the target handler if there are no more interceptors. + * + * @author edgar + * @since 4.2.0 + */ +public interface McpChain { + + /** + * Proceeds to the next interceptor in the chain or executes the target handler. + * + *

Interceptors can modify the {@link McpOperation} (e.g., sanitizing arguments) before passing + * it down the chain. + * + * @param exchange The stateful server exchange, or {@code null} if running in a stateless + * context. + * @param transportContext The transport context for the current connection. + * @param operation The operation context containing the routing ID and arguments. + * @return The result of the operation execution. + * @throws Exception If the downstream execution fails. + */ + R proceed( + @Nullable McpSyncServerExchange exchange, + McpTransportContext transportContext, + McpOperation operation) + throws Exception; +} 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 52fba64617..a7d7b16a12 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 @@ -6,8 +6,14 @@ package io.jooby.mcp; import java.util.Objects; +import java.util.function.BiFunction; + +import org.jspecify.annotations.Nullable; import io.jooby.SneakyThrows; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpSchema; /** * Intercepts and wraps the execution of MCP (Model Context Protocol) operations, such as tools, @@ -18,6 +24,10 @@ * (SLF4J MDC), transaction management, or custom error handling—right before and after an operation * executes. * + *

Additionally, it serves as a factory for adapting framework-agnostic handler functions into + * the specific functional interfaces required by the underlying MCP Java SDK for both stateful and + * stateless servers. + * *

Chaining Invokers

* *

Jooby provides a default internal invoker that gracefully maps standard framework exceptions @@ -29,19 +39,19 @@ * *

{@code
  * public class MdcMcpInvoker implements McpInvoker {
- * public  R invoke(String operationId, SneakyThrows.Supplier action) {
+ * @Override
+ * public  R invoke(@Nullable McpSyncServerExchange exchange, McpTransportContext transportContext, McpOperation operation, McpChain chain) throws Exception {
  * try {
- * MDC.put("mcp.operation", operationId);
+ * MDC.put("mcp.operation", operation.id());
  * // Execute the actual tool or proceed to the next invoker in the chain
- * return action.get();
+ * return (R) chain.proceed(exchange, transportContext, operation);
  * } finally {
  * MDC.remove("mcp.operation");
  * }
  * }
  * }
  * * // Register and automatically chain it:
- * install(new McpModule(new MyServiceMcp_())
- * .invoker(new MdcMcpInvoker()));
+ * install(new McpModule(new MyServiceMcp_()).invoker(new MdcMcpInvoker()));
  * }
* * @author edgar @@ -50,15 +60,369 @@ public interface McpInvoker { /** - * Executes the given MCP operation. + * Executes the given MCP operation, allowing for pre- and post-processing. * - * @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. + * @param exchange The stateful server exchange, or {@code null} if running in a stateless + * context. + * @param transportContext The transport context for the current connection. + * @param operation The operation context containing the routing metadata and arguments. + * @param next The chain used to proceed to the next invoker or the final handler. + * @param The expected return type of the MCP operation result. * @return The result of the operation. + * @throws Exception If an error occurs during execution. + */ + R invoke( + @Nullable McpSyncServerExchange exchange, + McpTransportContext transportContext, + McpOperation operation, + McpChain next) + throws Exception; + + /** + * Adapts a framework function into a stateful Tool handler for the MCP SDK. + * + * @param operationId The standard MCP routing identifier (e.g., "tools/add_numbers"). + * @param classname The fully qualified name of the target class. + * @param method The name of the target method. + * @param fn The framework function to execute. + * @return A {@link BiFunction} compatible with {@code McpSyncServer}. + */ + default BiFunction + asToolHandler( + String operationId, + String classname, + String method, + SneakyThrows.Function3< + McpSyncServerExchange, + McpTransportContext, + McpOperation, + McpSchema.CallToolResult> + fn) { + return (exchange, req) -> { + var operation = McpOperation.create(operationId, classname, method, req); + try { + return McpInvoker.this.invoke( + exchange, + exchange.transportContext(), + operation, + new McpChain() { + @SuppressWarnings("unchecked") + @Override + public McpSchema.CallToolResult proceed( + @Nullable McpSyncServerExchange exchange, + McpTransportContext transportContext, + McpOperation operation) { + return fn.apply(exchange, transportContext, operation); + } + }); + } catch (Exception e) { + throw SneakyThrows.propagate(e); + } + }; + } + + /** + * Adapts a framework function into a stateless Tool handler for the MCP SDK. + * + * @param operationId The standard MCP routing identifier (e.g., "tools/add_numbers"). + * @param classname The fully qualified name of the target class. + * @param method The name of the target method. + * @param fn The framework function to execute. + * @return A {@link BiFunction} compatible with {@code McpStatelessSyncServer}. + */ + default BiFunction + asStatelessToolHandler( + String operationId, + String classname, + String method, + SneakyThrows.Function3< + McpSyncServerExchange, + McpTransportContext, + McpOperation, + McpSchema.CallToolResult> + fn) { + return (transportContext, req) -> { + var operation = McpOperation.create(operationId, classname, method, req); + try { + return McpInvoker.this.invoke( + null, + transportContext, + operation, + new McpChain() { + @SuppressWarnings("unchecked") + @Override + public McpSchema.CallToolResult proceed( + @Nullable McpSyncServerExchange exchange, + McpTransportContext transportContext, + McpOperation operation) { + return fn.apply(exchange, transportContext, operation); + } + }); + } catch (Exception e) { + throw SneakyThrows.propagate(e); + } + }; + } + + /** + * Adapts a framework function into a stateful Prompt handler for the MCP SDK. + * + * @param operationId The standard MCP routing identifier (e.g., "tools/add_numbers"). + * @param classname The fully qualified name of the target class. + * @param method The name of the target method. + * @param fn The framework function to execute. + * @return A {@link BiFunction} compatible with {@code McpSyncServer}. + */ + default BiFunction + asPromptHandler( + String operationId, + String classname, + String method, + SneakyThrows.Function3< + McpSyncServerExchange, + McpTransportContext, + McpOperation, + McpSchema.GetPromptResult> + fn) { + return (exchange, req) -> { + var operation = McpOperation.create(operationId, classname, method, req); + try { + return McpInvoker.this.invoke( + exchange, + exchange.transportContext(), + operation, + new McpChain() { + @SuppressWarnings("unchecked") + @Override + public McpSchema.GetPromptResult proceed( + @Nullable McpSyncServerExchange exchange, + McpTransportContext transportContext, + McpOperation operation) { + return fn.apply(exchange, transportContext, operation); + } + }); + } catch (Exception e) { + throw SneakyThrows.propagate(e); + } + }; + } + + /** + * Adapts a framework function into a stateless Prompt handler for the MCP SDK. + * + * @param operationId The standard MCP routing identifier (e.g., "tools/add_numbers"). + * @param classname The fully qualified name of the target class. + * @param method The name of the target method. + * @param fn The framework function to execute. + * @return A {@link BiFunction} compatible with {@code McpStatelessSyncServer}. + */ + default BiFunction + asStatelessPromptHandler( + String operationId, + String classname, + String method, + SneakyThrows.Function3< + McpSyncServerExchange, + McpTransportContext, + McpOperation, + McpSchema.GetPromptResult> + fn) { + return (transportContext, req) -> { + var operation = McpOperation.create(operationId, classname, method, req); + try { + return McpInvoker.this.invoke( + null, + transportContext, + operation, + new McpChain() { + @SuppressWarnings("unchecked") + @Override + public McpSchema.GetPromptResult proceed( + @Nullable McpSyncServerExchange exchange, + McpTransportContext transportContext, + McpOperation operation) { + return fn.apply(exchange, transportContext, operation); + } + }); + } catch (Exception e) { + throw SneakyThrows.propagate(e); + } + }; + } + + /** + * Adapts a framework function into a stateful Resource handler for the MCP SDK. + * + * @param operationId The standard MCP routing identifier (e.g., "tools/add_numbers"). + * @param classname The fully qualified name of the target class. + * @param method The name of the target method. + * @param fn The framework function to execute. + * @return A {@link BiFunction} compatible with {@code McpSyncServer}. */ - R invoke(McpOperation operation, SneakyThrows.Supplier action); + default BiFunction< + McpSyncServerExchange, McpSchema.ReadResourceRequest, McpSchema.ReadResourceResult> + asResourceHandler( + String operationId, + String classname, + String method, + SneakyThrows.Function3< + McpSyncServerExchange, + McpTransportContext, + McpOperation, + McpSchema.ReadResourceResult> + fn) { + return (exchange, req) -> { + var operation = McpOperation.create(operationId, classname, method, req); + try { + return McpInvoker.this.invoke( + exchange, + exchange.transportContext(), + operation, + new McpChain() { + @SuppressWarnings("unchecked") + @Override + public McpSchema.ReadResourceResult proceed( + @Nullable McpSyncServerExchange exchange, + McpTransportContext transportContext, + McpOperation operation) { + return fn.apply(exchange, transportContext, operation); + } + }); + } catch (Exception e) { + throw SneakyThrows.propagate(e); + } + }; + } + + /** + * Adapts a framework function into a stateless Resource handler for the MCP SDK. + * + * @param operationId The standard MCP routing identifier (e.g., "tools/add_numbers"). + * @param classname The fully qualified name of the target class. + * @param method The name of the target method. + * @param fn The framework function to execute. + * @return A {@link BiFunction} compatible with {@code McpStatelessSyncServer}. + */ + default BiFunction< + McpTransportContext, McpSchema.ReadResourceRequest, McpSchema.ReadResourceResult> + asStatelessResourceHandler( + String operationId, + String classname, + String method, + SneakyThrows.Function3< + McpSyncServerExchange, + McpTransportContext, + McpOperation, + McpSchema.ReadResourceResult> + fn) { + return (transportContext, req) -> { + var operation = McpOperation.create(operationId, classname, method, req); + try { + return McpInvoker.this.invoke( + null, + transportContext, + operation, + new McpChain() { + @SuppressWarnings("unchecked") + @Override + public McpSchema.ReadResourceResult proceed( + @Nullable McpSyncServerExchange exchange, + McpTransportContext transportContext, + McpOperation operation) { + return fn.apply(exchange, transportContext, operation); + } + }); + } catch (Exception e) { + throw SneakyThrows.propagate(e); + } + }; + } + + /** + * Adapts a framework function into a stateful Completion handler for the MCP SDK. + * + * @param operationId The standard MCP routing identifier (e.g., "tools/add_numbers"). + * @param classname The fully qualified name of the target class. + * @param method The name of the target method. + * @param fn The framework function to execute. + * @return A {@link BiFunction} compatible with {@code McpSyncServer}. + */ + default BiFunction + asCompletionHandler( + String operationId, + String classname, + String method, + SneakyThrows.Function3< + McpSyncServerExchange, + McpTransportContext, + McpOperation, + McpSchema.CompleteResult> + fn) { + return (exchange, req) -> { + var operation = McpOperation.create(operationId, classname, method, req); + try { + return McpInvoker.this.invoke( + exchange, + exchange.transportContext(), + operation, + new McpChain() { + @SuppressWarnings("unchecked") + @Override + public McpSchema.CompleteResult proceed( + @Nullable McpSyncServerExchange exchange, + McpTransportContext transportContext, + McpOperation operation) { + return fn.apply(exchange, transportContext, operation); + } + }); + } catch (Exception e) { + throw SneakyThrows.propagate(e); + } + }; + } + + /** + * Adapts a framework function into a stateless Completion handler for the MCP SDK. + * + * @param operationId The standard MCP routing identifier (e.g., "tools/add_numbers"). + * @param classname The fully qualified name of the target class. + * @param method The name of the target method. + * @param fn The framework function to execute. + * @return A {@link BiFunction} compatible with {@code McpStatelessSyncServer}. + */ + default BiFunction + asStatelessCompletionHandler( + String operationId, + String classname, + String method, + SneakyThrows.Function3< + McpSyncServerExchange, + McpTransportContext, + McpOperation, + McpSchema.CompleteResult> + fn) { + return (transportContext, req) -> { + var operation = McpOperation.create(operationId, classname, method, req); + try { + return McpInvoker.this.invoke( + null, + transportContext, + operation, + new McpChain() { + @SuppressWarnings("unchecked") + @Override + public McpSchema.CompleteResult proceed( + @Nullable McpSyncServerExchange exchange, + McpTransportContext transportContext, + McpOperation operation) { + return fn.apply(exchange, transportContext, operation); + } + }); + } catch (Exception e) { + throw SneakyThrows.propagate(e); + } + }; + } /** * Chains this invoker with another one. This invoker runs first, and its "action" becomes calling @@ -74,8 +438,26 @@ default McpInvoker then(McpInvoker next) { Objects.requireNonNull(next, "next invoker is required"); return new McpInvoker() { @Override - public R invoke(McpOperation operation, SneakyThrows.Supplier action) { - return McpInvoker.this.invoke(operation, () -> next.invoke(operation, action)); + public R invoke( + @Nullable McpSyncServerExchange exchange, + McpTransportContext transportContext, + McpOperation operation, + McpChain chain) + throws Exception { + return McpInvoker.this.invoke( + exchange, + transportContext, + operation, + new McpChain() { + @Override + public T proceed( + @Nullable McpSyncServerExchange chainExchange, + McpTransportContext chainTransportContext, + McpOperation chainOperation) + throws Exception { + return next.invoke(chainExchange, chainTransportContext, chainOperation, chain); + } + }); } }; } 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 cccc4d388d..a3ac6ee810 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,7 @@ import java.util.*; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -19,7 +20,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.McpDefaultInvoker; import io.jooby.internal.mcp.McpServerConfig; import io.jooby.internal.mcp.transport.SseTransportProvider; import io.jooby.internal.mcp.transport.StatelessTransportProvider; @@ -151,7 +152,7 @@ public class McpModule implements Extension { private final List mcpServices = new ArrayList<>(); - private McpInvoker invoker; + private @Nullable McpInvoker invoker; private Boolean generateOutputSchema = null; @@ -167,9 +168,7 @@ public class McpModule implements Extension { */ public McpModule(McpService mcpService, McpService... mcpServices) { this.mcpServices.add(mcpService); - if (mcpServices != null) { - Collections.addAll(this.mcpServices, mcpServices); - } + Collections.addAll(this.mcpServices, mcpServices); } /** @@ -230,11 +229,11 @@ public void install(Jooby app) { ? app.getConfig().getBoolean("mcp.generateOutputSchema") : Optional.ofNullable(this.generateOutputSchema).orElse(Boolean.FALSE); // invoker - McpInvoker firstInvoker = new DefaultMcpInvoker(app); + McpInvoker pipeline = new McpDefaultInvoker(app); if (this.invoker != null) { - firstInvoker = firstInvoker.then(this.invoker); + pipeline = pipeline.then(this.invoker); } - services.put(McpInvoker.class, firstInvoker); + services.put(McpInvoker.class, pipeline); // 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 index 8ecd292b80..37c5355acd 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpOperation.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpOperation.java @@ -5,13 +5,197 @@ */ package io.jooby.mcp; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.CompleteRequest; +import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; +import io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest; + /** - * Contextual information about an MCP operation being invoked. + * Contextual information about an MCP (Model Context Protocol) operation being invoked. + * + *

It acts as a unified data transfer object (DTO) that holds the routing identifier, the target + * execution method, and the MCP request for any type of MCP request (tools, prompts, resources, or + * completions). * - * @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) {} +public class McpOperation { + private final String id; + private final String className; + private final String methodName; + private final McpSchema.Request request; + private final ConcurrentMap arguments; + + private McpOperation(String id, String className, String methodName, McpSchema.Request request) { + this.id = id; + this.className = className; + this.methodName = methodName; + this.request = request; + this.arguments = new ConcurrentHashMap<>(arguments(request)); + } + + /** + * Determines if the current request is an instance of {@code McpSchema.CallToolRequest}. + * + * @return {@code true} if the request is a {@code McpSchema.CallToolRequest}, otherwise {@code + * false}. + */ + public boolean isTool() { + return request instanceof McpSchema.CallToolRequest; + } + + /** + * The standard MCP routing identifier (e.g., "tools/add_numbers" or "resources/config.json"). + * + * @return The standard MCP routing identifier (e.g., "tools/add_numbers" or + * "resources/config.json"). + */ + public String getId() { + return id; + } + + /** + * Retrieves the name of the class associated with this operation. + * + * @return The name of the target class. + */ + public String getClassName() { + return className; + } + + /** + * Retrieves the name of the method associated with this operation. + * + * @return The name of the target method. + */ + public String getMethodName() { + return methodName; + } + + /** + * Retrieves a map of arguments associated with the current request. + * + *

Depending on the type of the request, this method performs the following: - If the request + * is a {@code McpSchema.CallToolRequest}, the arguments from the request are returned. - If the + * request is a {@code GetPromptRequest}, the arguments from the request are returned. - If the + * request is a {@code CompleteRequest}, it extracts the name and value of the argument (if + * present) and returns them as a map. If the argument or its value is missing, an empty map is + * returned. - For any other request type, an empty map is returned. + * + * @return A map containing argument names as keys and their corresponding values. If no arguments + * are present, an empty map is returned. + */ + public Map arguments() { + return arguments; + } + + private Map arguments(McpSchema.Request request) { + return switch (request) { + case McpSchema.CallToolRequest callToolRequest -> callToolRequest.arguments(); + case GetPromptRequest getPromptRequest -> getPromptRequest.arguments(); + case CompleteRequest completeRequest -> + completeRequest.argument() != null && completeRequest.argument().value() != null + ? Map.of( + "name", + completeRequest.argument().name(), + "value", + completeRequest.argument().value()) + : Map.of(); + default -> Map.of(); + }; + } + + /** + * Casts and retrieves the request associated with the current operation as the specified type. + * + * @param The type to which the request will be cast. + * @param type The {@code Class} object representing the type to which the request should be cast. + * Must not be null. + * @return The request cast to the specified type. + * @throws ClassCastException If the request cannot be cast to the specified type. + */ + public R request(Class type) { + return type.cast(request); + } + + /** + * Retrieves the request associated with the current operation. + * + * @return The {@code McpSchema.Request} object representing the current operation's request. + */ + public McpSchema.Request getRequest() { + return request; + } + + /** + * Sets an argument for the current operation. + * + * @param name The name of the argument to set. Must not be null. + * @param value The value of the argument to associate with the specified name. Can be null. + */ + public void setArgument(String name, Object value) { + this.arguments.put(name, value); + } + + /** + * Creates an operation context for a Tool invocation. + * + * @param operationId The standard MCP routing identifier (e.g., "tools/add_numbers"). + * @param targetClass The fully qualified name of the target class. + * @param targetMethod The name of the target method. + * @param req The incoming tool request. + * @return A populated operation context containing the tool name and arguments. + */ + public static McpOperation create( + String operationId, String targetClass, String targetMethod, McpSchema.CallToolRequest req) { + return new McpOperation(operationId, targetClass, targetMethod, req); + } + + /** + * Creates an operation context for a Prompt invocation. + * + * @param operationId The standard MCP routing identifier (e.g., "prompts/add_numbers"). + * @param targetClass The fully qualified name of the target class. + * @param targetMethod The name of the target method. + * @param req The incoming prompt request. + * @return A populated operation context containing the prompt name and arguments. + */ + public static McpOperation create( + String operationId, String targetClass, String targetMethod, GetPromptRequest req) { + return new McpOperation(operationId, targetClass, targetMethod, req); + } + + /** + * Creates an operation context for a Resource read. + * + * @param operationId The standard MCP routing identifier (e.g., "resources/config.json"). + * @param targetClass The fully qualified name of the target class. + * @param targetMethod The name of the target method. + * @param req The incoming resource request. + * @return A populated operation context containing the resource URI. + */ + public static McpOperation create( + String operationId, String targetClass, String targetMethod, ReadResourceRequest req) { + return new McpOperation(operationId, targetClass, targetMethod, req); + } + + /** + * Creates an operation context for an Autocomplete request. + * + * @param operationId The standard MCP routing identifier (e.g., "completions/add_numbers"). + * @param targetClass The fully qualified name of the target class. + * @param targetMethod The name of the target method. + * @param req The incoming completion request. + * @return A populated operation context containing the completion reference and partial argument + * values. + */ + public static McpOperation create( + String operationId, String targetClass, String targetMethod, CompleteRequest req) { + return new McpOperation(operationId, targetClass, targetMethod, req); + } +} diff --git a/tests/src/test/java/io/jooby/i3830/ArgumentModifier.java b/tests/src/test/java/io/jooby/i3830/ArgumentModifier.java new file mode 100644 index 0000000000..ab64369721 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3830/ArgumentModifier.java @@ -0,0 +1,24 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3830; + +import io.jooby.annotation.mcp.McpTool; +import io.modelcontextprotocol.spec.McpSchema; + +/** A collection of tools, prompts, and resources exposed to the LLM via MCP. */ +public class ArgumentModifier { + + /** + * Retrieves the username from the provided custom argument. + * + * @param user The custom argument containing user information. + * @return The username extracted from the provided custom argument. + */ + @McpTool(name = "customArgument") + public String customizer(CustomArg user, McpSchema.CallToolRequest req) { + return user.username(); + } +} diff --git a/tests/src/test/java/io/jooby/i3830/CustomArg.java b/tests/src/test/java/io/jooby/i3830/CustomArg.java new file mode 100644 index 0000000000..d76a0cdcf2 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3830/CustomArg.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 record CustomArg(String username) {} diff --git a/tests/src/test/java/io/jooby/i3830/McpArgumentModifierTest.java b/tests/src/test/java/io/jooby/i3830/McpArgumentModifierTest.java new file mode 100644 index 0000000000..6e5a4344ef --- /dev/null +++ b/tests/src/test/java/io/jooby/i3830/McpArgumentModifierTest.java @@ -0,0 +1,124 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3830; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import io.jooby.Jooby; +import io.jooby.jackson3.Jackson3Module; +import io.jooby.junit.ServerTest; +import io.jooby.junit.ServerTestRunner; +import io.jooby.mcp.McpChain; +import io.jooby.mcp.McpInvoker; +import io.jooby.mcp.McpModule; +import io.jooby.mcp.McpOperation; +import io.jooby.mcp.jackson3.McpJackson3Module; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpSyncServerExchange; + +public class McpArgumentModifierTest { + + private static final UUID uuid = UUID.randomUUID(); + + private void setupMcpApp(Jooby app, McpModule.Transport transport) { + app.install(new Jackson3Module()); + app.install(new McpJackson3Module()); + app.install( + new McpModule(new ArgumentModifierMcp_()) + .invoker( + new McpInvoker() { + @Override + public R invoke( + @Nullable McpSyncServerExchange exchange, + @NonNull McpTransportContext transportContext, + @NonNull McpOperation operation, + @NonNull McpChain next) + throws Exception { + operation.setArgument("user", new CustomArg(uuid.toString())); + return next.proceed(exchange, transportContext, operation); + } + }) + .transport(transport)); + } + + @ServerTest + public void shouldIntroduceArguments(ServerTestRunner runner) { + runner + .define(app -> setupMcpApp(app, McpModule.Transport.STREAMABLE_HTTP)) + .ready( + client -> { + AtomicReference sessionId = new AtomicReference<>(); + + 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": "customArgument", + "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("\"text\":\"%s\"".formatted(uuid.toString())); + }); + }); + } +} diff --git a/tests/src/test/java/io/jooby/i3830/CalculatorToolsTest.java b/tests/src/test/java/io/jooby/i3830/McpCalculatorToolsTest.java similarity index 95% rename from tests/src/test/java/io/jooby/i3830/CalculatorToolsTest.java rename to tests/src/test/java/io/jooby/i3830/McpCalculatorToolsTest.java index 1346840f3b..6b6dc68b07 100644 --- a/tests/src/test/java/io/jooby/i3830/CalculatorToolsTest.java +++ b/tests/src/test/java/io/jooby/i3830/McpCalculatorToolsTest.java @@ -18,19 +18,41 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + import io.jooby.Jooby; import io.jooby.jackson3.Jackson3Module; import io.jooby.junit.ServerTest; import io.jooby.junit.ServerTestRunner; +import io.jooby.mcp.McpChain; +import io.jooby.mcp.McpInvoker; import io.jooby.mcp.McpModule; +import io.jooby.mcp.McpOperation; import io.jooby.mcp.jackson3.McpJackson3Module; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpSyncServerExchange; -public class CalculatorToolsTest { +public class McpCalculatorToolsTest { private void setupMcpApp(Jooby app, McpModule.Transport transport) { app.install(new Jackson3Module()); app.install(new McpJackson3Module()); - app.install(new McpModule(new CalculatorToolsMcp_()).transport(transport)); + app.install( + new McpModule(new CalculatorToolsMcp_()) + .invoker( + new McpInvoker() { + @Override + public R invoke( + @Nullable McpSyncServerExchange exchange, + @NonNull McpTransportContext transportContext, + @NonNull McpOperation operation, + @NonNull McpChain next) + throws Exception { + return next.proceed(exchange, transportContext, operation); + } + }) + .transport(transport)); } @ServerTest diff --git a/tests/src/test/java/io/jooby/i3830/McpExchangeInjectionTest.java b/tests/src/test/java/io/jooby/i3830/McpTestExchangeInjectionTest.java similarity index 98% rename from tests/src/test/java/io/jooby/i3830/McpExchangeInjectionTest.java rename to tests/src/test/java/io/jooby/i3830/McpTestExchangeInjectionTest.java index 55422c64eb..a56d0e9735 100644 --- a/tests/src/test/java/io/jooby/i3830/McpExchangeInjectionTest.java +++ b/tests/src/test/java/io/jooby/i3830/McpTestExchangeInjectionTest.java @@ -17,7 +17,7 @@ import io.jooby.mcp.McpModule; import io.jooby.mcp.jackson3.McpJackson3Module; -public class McpExchangeInjectionTest { +public class McpTestExchangeInjectionTest { @ServerTest public void shouldInjectExchangeAndAccessSession(ServerTestRunner runner) throws Exception { diff --git a/tests/src/test/java/io/jooby/i3830/UserToolsTest.java b/tests/src/test/java/io/jooby/i3830/McpTestUserToolsTest.java similarity index 98% rename from tests/src/test/java/io/jooby/i3830/UserToolsTest.java rename to tests/src/test/java/io/jooby/i3830/McpTestUserToolsTest.java index 08afc2d823..7154c81993 100644 --- a/tests/src/test/java/io/jooby/i3830/UserToolsTest.java +++ b/tests/src/test/java/io/jooby/i3830/McpTestUserToolsTest.java @@ -21,7 +21,7 @@ import io.jooby.mcp.jackson3.McpJackson3Module; import io.jooby.test.WebClient; -public class UserToolsTest { +public class McpTestUserToolsTest { private void setupMcpApp(Jooby app, Extension... extensions) { for (var extension : extensions) {