Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 65 additions & 7 deletions docs/asciidoc/modules/mcp.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -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> R invoke(McpOperation operation, SneakyThrows.Supplier<R> action) {
public <R> 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");
Expand All @@ -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> 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -333,7 +335,8 @@ private List<String> 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);
Expand Down Expand Up @@ -461,7 +464,8 @@ private List<String> 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();
Expand Down Expand Up @@ -809,15 +813,19 @@ public List<String> 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(
Expand All @@ -827,37 +835,36 @@ public List<String> 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<String, Any>()"));
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.<String, Object>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");
boolean isTemplate = isMcpResourceTemplate();

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),
Expand All @@ -867,7 +874,7 @@ public List<String> generateMcpHandlerMethod(boolean kt) {
buffer.add(statement(indent(6), "val args = mutableMapOf<String, Any>()"));
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),
Expand Down Expand Up @@ -911,7 +918,11 @@ public List<String> 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;
}

Expand Down Expand Up @@ -1070,7 +1081,7 @@ public List<String> 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 = "";
Expand Down
Loading
Loading