Skip to content

Commit d343a4d

Browse files
committed
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.
1 parent 6b602e2 commit d343a4d

17 files changed

Lines changed: 1043 additions & 181 deletions

File tree

docs/asciidoc/modules/mcp.adoc

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ public class UserService {
189189
<1> Forces array schema generation for `User`, overriding generic `Object` erasure and the global/config flag.
190190
<2> Explicitly disables schema generation for this specific tool.
191191

192-
=== Custom Invokers & Telemetry
192+
=== Custom Invokers
193193

194194
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`.
195195

@@ -204,16 +204,21 @@ Invokers are chained. You can register multiple invokers and they will wrap the
204204
----
205205
import io.jooby.mcp.McpInvoker;
206206
import io.jooby.mcp.McpOperation;
207+
import io.jooby.mcp.McpChain;
208+
import io.modelcontextprotocol.common.McpTransportContext;
209+
import io.modelcontextprotocol.server.McpSyncServerExchange;
210+
import org.jspecify.annotations.Nullable;
207211
import org.slf4j.MDC;
208212
209213
public class MdcMcpInvoker implements McpInvoker {
210214
@Override
211-
public <R> R invoke(McpOperation operation, SneakyThrows.Supplier<R> action) {
215+
public <R> R invoke(@Nullable McpSyncServerExchange exchange, McpTransportContext transportContext, McpOperation operation, McpChain chain) throws Exception {
212216
try {
213-
MDC.put("mcp.id", operation.id()); <1>
217+
MDC.put("mcp.id", operation.id()); // <1>
214218
MDC.put("mcp.class", operation.className());
215219
MDC.put("mcp.method", operation.methodName());
216-
return action.get(); <2>
220+
221+
return chain.proceed(exchange, transportContext, operation); // <2>
217222
} finally {
218223
MDC.remove("mcp.id");
219224
MDC.remove("mcp.class");
@@ -224,13 +229,66 @@ public class MdcMcpInvoker implements McpInvoker {
224229
225230
{
226231
install(new McpModule(new CalculatorServiceMcp_())
227-
.invoker(new MdcMcpInvoker())); <3>
232+
.invoker(new MdcMcpInvoker())); // <3>
228233
}
229234
----
230235

231236
<1> Extract rich contextual data from the `McpOperation` record.
232-
<2> Proceed with the execution chain.
233-
<3> Register the invoker. Jooby will safely map any business exceptions thrown by your action into valid MCP JSON-RPC errors.
237+
<2> Proceed to the next interceptor in the chain or execute the final target handler.
238+
<3> Register the invoker. Jooby will safely map any business exceptions thrown by your chain into valid MCP JSON-RPC errors.
239+
240+
==== Context Augmentation
241+
242+
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`.
243+
244+
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.
245+
246+
.Context Injector Example
247+
[source, java]
248+
----
249+
import io.jooby.mcp.McpInvoker;
250+
import io.jooby.mcp.McpOperation;
251+
import io.jooby.mcp.McpChain;
252+
import io.modelcontextprotocol.common.McpTransportContext;
253+
import io.modelcontextprotocol.server.McpSyncServerExchange;
254+
import org.jspecify.annotations.Nullable;
255+
256+
public class UserContextInvoker implements McpInvoker {
257+
@Override
258+
@SuppressWarnings("unchecked")
259+
public <R> R invoke(@Nullable McpSyncServerExchange exchange, McpTransportContext transportContext, McpOperation operation, McpChain chain) throws Exception {
260+
261+
User currentUser = retrieveUser();
262+
263+
// 2. Augment the operation with the resolved user
264+
operation.setArgument("user", currentUser);
265+
266+
// 3. Proceed with the augmented operation
267+
return (R) chain.proceed(exchange, transportContext, augmentedOp);
268+
}
269+
}
270+
----
271+
272+
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.
273+
274+
.Tool Implementation
275+
[source, java]
276+
----
277+
import io.jooby.annotation.mcp.McpTool;
278+
279+
public class BillingService {
280+
281+
/**
282+
* @param user The authenticated user (injected by UserContextInvoker).
283+
* Note: Because it is a complex type not present in the JSON request,
284+
* it is safely ignored by the JSON schema generator.
285+
*/
286+
@McpTool(description = "Retrieves the billing history for the current user")
287+
public InvoiceHistory getMyInvoices(User user, int limit) {
288+
return database.findInvoices(user.getId(), limit);
289+
}
290+
}
291+
----
234292

235293
=== Multiple Servers
236294

modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
import javax.tools.*;
2525

2626
import io.jooby.internal.apt.*;
27-
27+
import io.jooby.internal.apt.mcp.McpRouter;
2828
import io.jooby.internal.apt.ws.WsRouter;
2929

3030
/** Process jooby/jakarta annotation and generate source code from MVC controllers. */

modules/jooby-apt/src/main/java/io/jooby/internal/apt/McpRoute.java renamed to modules/jooby-apt/src/main/java/io/jooby/internal/apt/mcp/McpRoute.java

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
44
* Copyright 2014 Edgar Espina
55
*/
6-
package io.jooby.internal.apt;
6+
package io.jooby.internal.apt.mcp;
77

88
import static io.jooby.internal.apt.CodeBlock.*;
99
import static io.jooby.internal.apt.CodeBlock.string;
@@ -15,6 +15,8 @@
1515

1616
import javax.lang.model.element.ExecutableElement;
1717

18+
import io.jooby.internal.apt.AnnotationSupport;
19+
import io.jooby.internal.apt.WebRoute;
1820
import io.jooby.javadoc.JavaDocNode;
1921
import io.jooby.javadoc.MethodDoc;
2022

@@ -333,7 +335,8 @@ private List<String> generatePromptDefinition(boolean kt) {
333335
var type = param.getType().getRawType().toString();
334336
if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange")
335337
|| type.equals("io.modelcontextprotocol.common.McpTransportContext")
336-
|| type.equals("io.jooby.Context")) continue;
338+
|| type.equals("io.jooby.Context")
339+
|| type.equals("io.jooby.mcp.McpOperation")) continue;
337340

338341
var mcpName = param.getMcpName();
339342
var isRequired = !param.isNullable(kt);
@@ -461,7 +464,8 @@ private List<String> generateToolDefinition(boolean kt) {
461464
var type = param.getType().getRawType().toString();
462465
if (type.equals("io.modelcontextprotocol.server.McpSyncServerExchange")
463466
|| type.equals("io.modelcontextprotocol.common.McpTransportContext")
464-
|| type.equals("io.jooby.Context")) continue;
467+
|| type.equals("io.jooby.Context")
468+
|| type.equals("io.jooby.mcp.McpOperation")) continue;
465469

466470
var mcpName = param.getMcpName();
467471
var paramDescription = param.getMcpDescription();
@@ -809,15 +813,19 @@ public List<String> generateMcpHandlerMethod(boolean kt) {
809813
"private fun ",
810814
handlerName,
811815
"(exchange: io.modelcontextprotocol.server.McpSyncServerExchange?, transportContext:"
812-
+ " io.modelcontextprotocol.common.McpTransportContext, req:"
813-
+ " io.modelcontextprotocol.spec.McpSchema.",
814-
reqType,
815-
"): io.modelcontextprotocol.spec.McpSchema.",
816+
+ " io.modelcontextprotocol.common.McpTransportContext, operation:"
817+
+ " io.jooby.mcp.McpOperation): io.modelcontextprotocol.spec.McpSchema.",
816818
resType,
817819
" {"));
818820

819821
buffer.add(
820822
statement(indent(6), "val ctx = transportContext.get(\"CTX\") as? io.jooby.Context"));
823+
buffer.add(
824+
statement(
825+
indent(6),
826+
"val req_ = operation.request(io.modelcontextprotocol.spec.McpSchema.",
827+
reqType,
828+
"::class.java)"));
821829
} else {
822830
buffer.add(
823831
statement(
@@ -827,37 +835,36 @@ public List<String> generateMcpHandlerMethod(boolean kt) {
827835
" ",
828836
handlerName,
829837
"(io.modelcontextprotocol.server.McpSyncServerExchange exchange,"
830-
+ " io.modelcontextprotocol.common.McpTransportContext"
831-
+ " transportContext,"
832-
+ " io.modelcontextprotocol.spec.McpSchema.",
833-
reqType,
834-
" req) {"));
838+
+ " io.modelcontextprotocol.common.McpTransportContext transportContext,"
839+
+ " io.jooby.mcp.McpOperation operation) {"));
835840

836841
buffer.add(
837842
statement(
838843
indent(6),
839844
"var ctx = (io.jooby.Context) transportContext.get(\"CTX\")",
840845
semicolon(kt)));
846+
buffer.add(
847+
statement(
848+
indent(6),
849+
"var req_ = operation.request(io.modelcontextprotocol.spec.McpSchema.",
850+
reqType,
851+
".class)",
852+
semicolon(kt)));
841853
}
842854

843855
if (isMcpTool() || isMcpPrompt()) {
844856
if (kt) {
845-
buffer.add(statement(indent(6), "val args = req.arguments() ?: emptyMap<String, Any>()"));
857+
buffer.add(statement(indent(6), "val args = operation.arguments()"));
846858
} else {
847-
buffer.add(
848-
statement(
849-
indent(6),
850-
"var args = req.arguments() != null ? req.arguments() :"
851-
+ " java.util.Collections.<String, Object>emptyMap()",
852-
semicolon(kt)));
859+
buffer.add(statement(indent(6), "var args = operation.arguments()", semicolon(kt)));
853860
}
854861
} else if (isMcpResource() || isMcpResourceTemplate()) {
855862
String uriTemplate = extractAnnotationValue("io.jooby.annotation.mcp.McpResource", "uri");
856863
boolean isTemplate = isMcpResourceTemplate();
857864

858865
if (isTemplate) {
859866
if (kt) {
860-
buffer.add(statement(indent(6), "val uri = req.uri()"));
867+
buffer.add(statement(indent(6), "val uri = req_.uri()"));
861868
buffer.add(
862869
statement(
863870
indent(6),
@@ -867,7 +874,7 @@ public List<String> generateMcpHandlerMethod(boolean kt) {
867874
buffer.add(statement(indent(6), "val args = mutableMapOf<String, Any>()"));
868875
buffer.add(statement(indent(6), "args.putAll(manager.extractVariableValues(uri))"));
869876
} else {
870-
buffer.add(statement(indent(6), "var uri = req.uri()", semicolon(kt)));
877+
buffer.add(statement(indent(6), "var uri = req_.uri()", semicolon(kt)));
871878
buffer.add(
872879
statement(
873880
indent(6),
@@ -911,7 +918,11 @@ public List<String> generateMcpHandlerMethod(boolean kt) {
911918
|| type.equals("io.modelcontextprotocol.common.McpTransportContext")) {
912919
continue;
913920
} else if (type.equals("io.modelcontextprotocol.spec.McpSchema." + reqType)) {
914-
buffer.add(statement(indent(6), kt ? "val " : "var ", javaName, " = req", semicolon(kt)));
921+
buffer.add(statement(indent(6), kt ? "val " : "var ", javaName, " = req_", semicolon(kt)));
922+
continue;
923+
} else if (type.equals("io.jooby.mcp.McpOperation")) {
924+
buffer.add(
925+
statement(indent(6), kt ? "val " : "var ", javaName, " = operation", semicolon(kt)));
915926
continue;
916927
}
917928

@@ -1070,7 +1081,7 @@ public List<String> generateMcpHandlerMethod(boolean kt) {
10701081

10711082
var methodCall = "c." + getMethodName() + "(" + String.join(", ", javaParamNames) + ")";
10721083

1073-
String toMethodPrefix = (isMcpResource() || isMcpResourceTemplate()) ? "req.uri(), " : "";
1084+
String toMethodPrefix = (isMcpResource() || isMcpResourceTemplate()) ? "req_.uri(), " : "";
10741085

10751086
// Resolve output schema flag for Handler runtime behavior
10761087
String toMethodSuffix = "";

0 commit comments

Comments
 (0)