From c0aaed1995e7cfee29bd9e0aa2d0f3fba232a069 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Fri, 24 Apr 2026 20:23:39 -0300 Subject: [PATCH] refactor(mcp): simplify generated code via runtime invoker adapters - Introduced `McpOperation` to cleanly encapsulate static routing metadata and runtime arguments. - Refactored `McpInvoker` to act as a factory, providing strongly-typed adapters (`asToolHandler`, `asCompletionHandler`, etc.) for both stateful and stateless servers. - Updated APT generator (`McpRouter`, `McpRoute`) to output clean method references instead of complex, boilerplate-heavy lambda chains. --- docs/asciidoc/modules/mcp.adoc | 17 +- .../java/io/jooby/apt/JoobyProcessor.java | 2 +- .../internal/apt/{ => mcp}/McpRoute.java | 4 +- .../internal/apt/{ => mcp}/McpRouter.java | 128 +++--- .../src/test/java/tests/i3830/Issue3830.java | 24 +- .../instrumentation/OtelJsonRcpTracing.java | 2 +- ...ultMcpInvoker.java => LoggingInvoker.java} | 24 +- .../src/main/java/io/jooby/mcp/McpChain.java | 43 ++ .../main/java/io/jooby/mcp/McpInvoker.java | 416 +++++++++++++++++- .../src/main/java/io/jooby/mcp/McpModule.java | 15 +- .../main/java/io/jooby/mcp/McpOperation.java | 85 +++- ...sTest.java => McpCalculatorToolsTest.java} | 26 +- ...java => McpTestExchangeInjectionTest.java} | 2 +- ...olsTest.java => McpTestUserToolsTest.java} | 2 +- 14 files changed, 659 insertions(+), 131 deletions(-) rename modules/jooby-apt/src/main/java/io/jooby/internal/apt/{ => mcp}/McpRoute.java (99%) rename modules/jooby-apt/src/main/java/io/jooby/internal/apt/{ => mcp}/McpRouter.java (90%) rename modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/{DefaultMcpInvoker.java => LoggingInvoker.java} (71%) create mode 100644 modules/jooby-mcp/src/main/java/io/jooby/mcp/McpChain.java rename tests/src/test/java/io/jooby/i3830/{CalculatorToolsTest.java => McpCalculatorToolsTest.java} (95%) rename tests/src/test/java/io/jooby/i3830/{McpExchangeInjectionTest.java => McpTestExchangeInjectionTest.java} (98%) rename tests/src/test/java/io/jooby/i3830/{UserToolsTest.java => McpTestUserToolsTest.java} (98%) diff --git a/docs/asciidoc/modules/mcp.adoc b/docs/asciidoc/modules/mcp.adoc index d63589193a..3e6c69083e 100644 --- a/docs/asciidoc/modules/mcp.adoc +++ b/docs/asciidoc/modules/mcp.adoc @@ -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,13 @@ 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. === 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 99% 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..8bea3006ec 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; 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 90% 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..869fea9ed0 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,31 @@ 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; // <--- ADD THIS + + // Update the string builder to include operationId: + 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 +394,7 @@ private void appendCompletions( "(", string(ref), "), ", - lambda, + invokerCall, "))")); } else { buffer.append( @@ -411,7 +407,7 @@ private void appendCompletions( "(", string(ref), "), ", - lambda, + invokerCall, "))", semicolon(kt))); } @@ -482,32 +478,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 +516,7 @@ private void appendInstall( "SyncToolSpecification(", methodName, "ToolSpec(schemaGenerator), ", - lambda, + invokerCall, "))", semicolon(kt))); } else if (route.isMcpPrompt()) { @@ -535,7 +529,7 @@ private void appendInstall( "SyncPromptSpecification(", methodName, "PromptSpec(), ", - lambda, + invokerCall, "))", semicolon(kt))); } else if (route.isMcpResource() || route.isMcpResourceTemplate()) { @@ -556,7 +550,7 @@ private void appendInstall( methodName, defMethod, ", ", - lambda, + invokerCall, "))", semicolon(kt))); } @@ -581,7 +575,7 @@ private void appendCompletionHandlers( + " io.modelcontextprotocol.spec.McpSchema.CompleteRequest):" + " io.modelcontextprotocol.spec.McpSchema.CompleteResult {")); buffer.append( - statement(indent(6), "val ctx = transportContext.get(\"CTX\") as io.jooby.Context")); + 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() ?: \"\"")); @@ -686,18 +680,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..87a31fa13c 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) { 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/LoggingInvoker.java similarity index 71% 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/LoggingInvoker.java index 608924522d..606deaf434 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/LoggingInvoker.java @@ -6,28 +6,34 @@ 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 LoggingInvoker implements McpInvoker { private final Jooby application; - public DefaultMcpInvoker(Jooby application) { + public LoggingInvoker(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) { @@ -35,8 +41,10 @@ public R invoke(@NonNull McpOperation operation, SneakyThrows.@NonNull Suppl 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(); + return McpSchema.CallToolResult.builder() + .addTextContent(errorMessage) + .isError(true) + .build(); } var statusCode = application.getRouter().errorCode(cause); if (statusCode.value() >= 500) { 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..12f4a8cee1 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,377 @@ 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, + McpSchema.CallToolRequest, + 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) + throws Exception { + return fn.apply(exchange, transportContext, req); + } + }); + } 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, + McpSchema.CallToolRequest, + 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) + throws Exception { + return fn.apply(exchange, transportContext, req); + } + }); + } 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, + McpSchema.GetPromptRequest, + 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) + throws Exception { + return fn.apply(exchange, transportContext, req); + } + }); + } 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, + McpSchema.GetPromptRequest, + 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) + throws Exception { + return fn.apply(exchange, transportContext, req); + } + }); + } 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, + McpSchema.ReadResourceRequest, + 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) + throws Exception { + return fn.apply(exchange, transportContext, req); + } + }); + } 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, + McpSchema.ReadResourceRequest, + 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) + throws Exception { + return fn.apply(exchange, transportContext, req); + } + }); + } 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, + McpSchema.CompleteRequest, + 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) + throws Exception { + return fn.apply(exchange, transportContext, req); + } + }); + } 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, + McpSchema.CompleteRequest, + 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) + throws Exception { + return fn.apply(exchange, transportContext, req); + } + }); + } catch (Exception e) { + throw SneakyThrows.propagate(e); + } + }; + } /** * Chains this invoker with another one. This invoker runs first, and its "action" becomes calling @@ -74,8 +446,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 Object proceed( + @Nullable McpSyncServerExchange exchange, + McpTransportContext transportContext, + McpOperation operation) + throws Exception { + return next.invoke(exchange, transportContext, operation, 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..8d587adb3f 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.LoggingInvoker; 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 LoggingInvoker(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..353217e60b 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,90 @@ */ package io.jooby.mcp; +import java.util.Map; + +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. * - * @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. + *

This record acts as a unified data transfer object (DTO) that holds the routing identifier, + * the target execution method, and the extracted arguments for any type of MCP request (tools, + * prompts, resources, or completions). + * + * @param id The standard MCP routing identifier (e.g., "tools/add_numbers" or + * "resources/config.json"). + * @param className The fully qualified name of the Java/Kotlin class hosting the target method. * @param methodName The name of the Java/Kotlin method being executed. + * @param arguments The arguments extracted from the request to be passed to the target method. * @author edgar * @since 4.2.0 */ -public record McpOperation(String id, String className, String methodName) {} +public record McpOperation( + String id, String className, String methodName, Map arguments) { + + /** + * 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.arguments()); + } + + /** + * 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.arguments()); + } + + /** + * 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, java.util.Map.of()); + } + + /** + * 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) { + Map args = + req.argument() != null && req.argument().value() != null + ? Map.of("name", req.argument().name(), "value", req.argument().value()) + : Map.of(); + + return new McpOperation(operationId, targetClass, targetMethod, args); + } +} 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) {