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
40 changes: 40 additions & 0 deletions docs/asciidoc/modules/opentelemetry.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,46 @@ import io.opentelemetry.api.OpenTelemetry
}
----

==== Model Context Protocol (MCP)

Provides automatic tracing for your MCP (Model Context Protocol) servers. By adding the `OtelMcpTracing` invoker to your MCP module pipeline, it generates a dedicated OpenTelemetry span for every MCP operation (tools, prompts, resources, and completions).

It strictly follows the official **OpenTelemetry GenAI and RPC Semantic Conventions**, ensuring seamless integration with modern APM and specialized AI observability dashboards. It prevents metric cardinality explosion by intelligently handling span names, and accurately records both protocol failures and MCP tool errors (which return `isError = true` rather than throwing exceptions).

.MCP Integration
[source, java, role = "primary"]
----
import io.jooby.mcp.McpModule;
import io.jooby.mcp.instrumentation.OtelMcpTracing;
import io.opentelemetry.api.OpenTelemetry;

{
install(new OtelModule());

// Register the MCP module and attach the tracing invoker
install(new McpModule(new CalculatorServiceMcp_())
.invoker(new OtelMcpTracing(require(OpenTelemetry.class)))
);
}
----

.Kotlin
[source, kt, role="secondary"]
----
import io.jooby.mcp.McpModule
import io.jooby.mcp.instrumentation.OtelMcpTracing
import io.opentelemetry.api.OpenTelemetry

{
install(OtelModule())

// Register the MCP module and attach the tracing invoker
install(McpModule(CalculatorServiceMcp_())
.invoker(OtelMcpTracing(require(OpenTelemetry::class.java)))
)
}
----

==== Log4j2

Seamlessly exports all application logs to your OpenTelemetry backend, automatically correlated with active trace and span IDs using a dynamic appender.
Expand Down
6 changes: 3 additions & 3 deletions modules/jooby-jsonrpc/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@
</dependency>

<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-api</artifactId>
<version>${opentelemetry.version}</version>
<groupId>io.jooby</groupId>
<artifactId>jooby-opentelemetry</artifactId>
<version>${jooby.version}</version>
<optional>true</optional>
</dependency>
</dependencies>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import io.jooby.Context;
import io.jooby.SneakyThrows;
import io.jooby.jsonrpc.*;
import io.jooby.opentelemetry.OtelContextExtractor;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.StatusCode;
Expand Down Expand Up @@ -44,6 +45,7 @@
* @since 4.5.0
*/
public class OtelJsonRcpTracing implements JsonRpcInvoker {
private final OpenTelemetry otel;

private final Tracer tracer;

Expand All @@ -57,6 +59,7 @@ public class OtelJsonRcpTracing implements JsonRpcInvoker {
* @param otel The OpenTelemetry instance used to obtain the tracer.
*/
public OtelJsonRcpTracing(OpenTelemetry otel) {
this.otel = otel;
tracer = otel.getTracer("io.jooby.jsonrpc");
}

Expand Down Expand Up @@ -101,6 +104,7 @@ public OtelJsonRcpTracing onEnd(SneakyThrows.Consumer3<Context, JsonRpcRequest,
public @NonNull Optional<JsonRpcResponse> invoke(
@NonNull Context ctx, @NonNull JsonRpcRequest request, @NonNull JsonRpcChain chain) {
var method = Optional.ofNullable(request.getMethod()).orElse("unknown_method");
var parent = ctx.require(OtelContextExtractor.class).extract(ctx);
var span =
tracer
.spanBuilder(method)
Expand All @@ -109,6 +113,7 @@ public OtelJsonRcpTracing onEnd(SneakyThrows.Consumer3<Context, JsonRpcRequest,
.setAttribute(
"rpc.jsonrpc.request_id",
Optional.ofNullable(request.getId()).map(Objects::toString).orElse(null))
.setParent(parent)
.startSpan();
try (var scope = span.makeCurrent()) {
if (onStart != null) {
Expand Down
3 changes: 2 additions & 1 deletion modules/jooby-jsonrpc/src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
* install(new JsonRpcModule(new MyServiceRpc_()));
* }</pre>
*
* @author Edgar Espina
* @author edgar
* @since 4.0.17
*/
module io.jooby.jsonrpc {
Expand All @@ -38,6 +38,7 @@
requires static org.jspecify;
requires typesafe.config;
requires org.slf4j;
requires static io.jooby.opentelemetry;
requires static io.opentelemetry.api;
requires static io.opentelemetry.context;
}
8 changes: 8 additions & 0 deletions modules/jooby-mcp/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp-core</artifactId>
</dependency>

<dependency>
<groupId>io.jooby</groupId>
<artifactId>jooby-opentelemetry</artifactId>
<version>${jooby.version}</version>
<optional>true</optional>
</dependency>

</dependencies>

<build>
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* Jooby https://jooby.io
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
* Copyright 2014 Edgar Espina
*/
package io.jooby.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 McpExecutor implements McpInvoker {
private final Jooby application;

public McpExecutor(Jooby application) {
this.application = application;
}

@SuppressWarnings("unchecked")
public @NonNull Object invoke(
@Nullable McpSyncServerExchange exchange,
@NonNull McpTransportContext transportContext,
@NonNull McpOperation operation,
@NonNull McpChain next) {
try {
return next.proceed(exchange, transportContext, operation);
} catch (Throwable cause) {
operation.exception(cause);
log(operation, cause);
if (SneakyThrows.isFatal(cause)) {
throw SneakyThrows.propagate(cause);
}
var code = toMcpErrorCode(cause);
if (operation.isTool()) {
// Tool error
var errorMessage =
cause.getMessage() != null ? cause.getMessage() : "Unknown error occurred";
var textContent = new McpSchema.TextContent(errorMessage);
return McpSchema.CallToolResult.builder().addContent(textContent).isError(true).build();
}
if (cause instanceof McpError mcpError) {
throw mcpError;
} else {
throw new McpError(
new McpSchema.JSONRPCResponse.JSONRPCError(code, cause.getMessage(), null));
}
}
}

private void log(McpOperation operation, Throwable cause) {
var log = LoggerFactory.getLogger(operation.getClassName());
var code = toMcpErrorCode(cause);
if (isServerError(code)) {
log.error("execution of {} resulted in exception", operation.getId(), cause);
} else {
log.debug("execution of {} resulted in exception", operation.getId(), cause);
}
}

static boolean isServerError(int code) {
// -32603 is Internal Error. Custom server errors usually fall outside the -32600 to -32699
// reserved range.
return code == McpSchema.ErrorCodes.INTERNAL_ERROR || code < -32700;
}

private int toMcpErrorCode(Throwable cause) {
if (cause instanceof McpError mcpError && mcpError.getJsonRpcError() != null) {
return mcpError.getJsonRpcError().code();
}
var statusCode = application.getRouter().errorCode(cause);
return switch (statusCode.value()) {
case StatusCode.BAD_REQUEST_CODE, StatusCode.CONFLICT_CODE ->
McpSchema.ErrorCodes.INVALID_PARAMS;
case StatusCode.NOT_FOUND_CODE -> McpSchema.ErrorCodes.RESOURCE_NOT_FOUND;

default -> McpSchema.ErrorCodes.INTERNAL_ERROR;
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,6 @@ public McpInspectorModule defaultServer(String mcpServerName) {
@Override
public void install(Jooby app) {
this.indexHtml = buildIndexHtml();
this.mcpSrvConfig = resolveMcpServerConfig(app);

app.assets(inspectorEndpoint + "/static/*", "/mcpInspector/assets/");

Expand All @@ -128,6 +127,11 @@ public void install(Jooby app) {
var configJson = buildConfigJson(mcpSrvConfig, location);
return ctx.setResponseType(MediaType.json).render(configJson);
});

app.onStarting(
() -> {
this.mcpSrvConfig = resolveMcpServerConfig(app);
});
}

private String buildIndexHtml() {
Expand Down
26 changes: 19 additions & 7 deletions modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@
import io.jooby.Jooby;
import io.jooby.ServiceKey;
import io.jooby.exception.StartupException;
import io.jooby.internal.mcp.McpDefaultInvoker;
import io.jooby.internal.mcp.McpExecutor;
import io.jooby.internal.mcp.McpServerConfig;
import io.jooby.internal.mcp.transport.SseTransportProvider;
import io.jooby.internal.mcp.transport.StatelessTransportProvider;
import io.jooby.internal.mcp.transport.StreamableTransportProvider;
import io.jooby.internal.mcp.transport.WebSocketTransportProvider;
import io.jooby.mcp.instrumentation.OtelMcpTracing;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.server.*;
Expand Down Expand Up @@ -153,8 +154,9 @@ public class McpModule implements Extension {
private final List<McpService> mcpServices = new ArrayList<>();

private @Nullable McpInvoker invoker;
private @Nullable OtelMcpTracing head;

private Boolean generateOutputSchema = null;
private @Nullable Boolean generateOutputSchema;

/**
* Creates a new MCP module initialized with the provided generated services.
Expand Down Expand Up @@ -200,10 +202,15 @@ public McpModule transport(Transport transport) {
* @return This module instance for method chaining.
*/
public McpModule invoker(McpInvoker invoker) {
if (this.invoker != null) {
this.invoker = invoker.then(this.invoker);
if (invoker instanceof OtelMcpTracing otel) {
// otel goes first:
this.head = otel;
} else {
this.invoker = invoker;
if (this.invoker != null) {
this.invoker = invoker.then(this.invoker);
} else {
this.invoker = invoker;
}
}
return this;
}
Expand All @@ -229,9 +236,14 @@ public void install(Jooby app) {
? app.getConfig().getBoolean("mcp.generateOutputSchema")
: Optional.ofNullable(this.generateOutputSchema).orElse(Boolean.FALSE);
// invoker
McpInvoker pipeline = new McpDefaultInvoker(app);
McpInvoker pipeline = new McpExecutor(app);
// Otel tracing goes first:
if (head != null) {
invoker = invoker == null ? head : head.then(invoker);
}
// Default invoker:
if (this.invoker != null) {
pipeline = pipeline.then(this.invoker);
pipeline = this.invoker.then(pipeline);
}
services.put(McpInvoker.class, pipeline);
// Group services by server
Expand Down
Loading
Loading