Skip to content

Commit c6dd2dd

Browse files
authored
Merge pull request #3929 from jooby-project/3928
feat(mcp): implement OpenTelemetry tracing instrumentation fix #3928
2 parents 9f1238c + e66c0f2 commit c6dd2dd

17 files changed

Lines changed: 652 additions & 133 deletions

File tree

docs/asciidoc/modules/opentelemetry.adoc

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,46 @@ import io.opentelemetry.api.OpenTelemetry
324324
}
325325
----
326326

327+
==== Model Context Protocol (MCP)
328+
329+
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).
330+
331+
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).
332+
333+
.MCP Integration
334+
[source, java, role = "primary"]
335+
----
336+
import io.jooby.mcp.McpModule;
337+
import io.jooby.mcp.instrumentation.OtelMcpTracing;
338+
import io.opentelemetry.api.OpenTelemetry;
339+
340+
{
341+
install(new OtelModule());
342+
343+
// Register the MCP module and attach the tracing invoker
344+
install(new McpModule(new CalculatorServiceMcp_())
345+
.invoker(new OtelMcpTracing(require(OpenTelemetry.class)))
346+
);
347+
}
348+
----
349+
350+
.Kotlin
351+
[source, kt, role="secondary"]
352+
----
353+
import io.jooby.mcp.McpModule
354+
import io.jooby.mcp.instrumentation.OtelMcpTracing
355+
import io.opentelemetry.api.OpenTelemetry
356+
357+
{
358+
install(OtelModule())
359+
360+
// Register the MCP module and attach the tracing invoker
361+
install(McpModule(CalculatorServiceMcp_())
362+
.invoker(OtelMcpTracing(require(OpenTelemetry::class.java)))
363+
)
364+
}
365+
----
366+
327367
==== Log4j2
328368

329369
Seamlessly exports all application logs to your OpenTelemetry backend, automatically correlated with active trace and span IDs using a dynamic appender.

modules/jooby-jsonrpc/pom.xml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@
2020
</dependency>
2121

2222
<dependency>
23-
<groupId>io.opentelemetry</groupId>
24-
<artifactId>opentelemetry-api</artifactId>
25-
<version>${opentelemetry.version}</version>
23+
<groupId>io.jooby</groupId>
24+
<artifactId>jooby-opentelemetry</artifactId>
25+
<version>${jooby.version}</version>
2626
<optional>true</optional>
2727
</dependency>
2828
</dependencies>

modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/instrumentation/OtelJsonRcpTracing.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import io.jooby.Context;
1515
import io.jooby.SneakyThrows;
1616
import io.jooby.jsonrpc.*;
17+
import io.jooby.opentelemetry.OtelContextExtractor;
1718
import io.opentelemetry.api.OpenTelemetry;
1819
import io.opentelemetry.api.trace.Span;
1920
import io.opentelemetry.api.trace.StatusCode;
@@ -44,6 +45,7 @@
4445
* @since 4.5.0
4546
*/
4647
public class OtelJsonRcpTracing implements JsonRpcInvoker {
48+
private final OpenTelemetry otel;
4749

4850
private final Tracer tracer;
4951

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

@@ -101,6 +104,7 @@ public OtelJsonRcpTracing onEnd(SneakyThrows.Consumer3<Context, JsonRpcRequest,
101104
public @NonNull Optional<JsonRpcResponse> invoke(
102105
@NonNull Context ctx, @NonNull JsonRpcRequest request, @NonNull JsonRpcChain chain) {
103106
var method = Optional.ofNullable(request.getMethod()).orElse("unknown_method");
107+
var parent = ctx.require(OtelContextExtractor.class).extract(ctx);
104108
var span =
105109
tracer
106110
.spanBuilder(method)
@@ -109,6 +113,7 @@ public OtelJsonRcpTracing onEnd(SneakyThrows.Consumer3<Context, JsonRpcRequest,
109113
.setAttribute(
110114
"rpc.jsonrpc.request_id",
111115
Optional.ofNullable(request.getId()).map(Objects::toString).orElse(null))
116+
.setParent(parent)
112117
.startSpan();
113118
try (var scope = span.makeCurrent()) {
114119
if (onStart != null) {

modules/jooby-jsonrpc/src/main/java/module-info.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
* install(new JsonRpcModule(new MyServiceRpc_()));
2828
* }</pre>
2929
*
30-
* @author Edgar Espina
30+
* @author edgar
3131
* @since 4.0.17
3232
*/
3333
module io.jooby.jsonrpc {
@@ -38,6 +38,7 @@
3838
requires static org.jspecify;
3939
requires typesafe.config;
4040
requires org.slf4j;
41+
requires static io.jooby.opentelemetry;
4142
requires static io.opentelemetry.api;
4243
requires static io.opentelemetry.context;
4344
}

modules/jooby-mcp/pom.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@
2222
<groupId>io.modelcontextprotocol.sdk</groupId>
2323
<artifactId>mcp-core</artifactId>
2424
</dependency>
25+
26+
<dependency>
27+
<groupId>io.jooby</groupId>
28+
<artifactId>jooby-opentelemetry</artifactId>
29+
<version>${jooby.version}</version>
30+
<optional>true</optional>
31+
</dependency>
32+
2533
</dependencies>
2634

2735
<build>

modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/McpDefaultInvoker.java

Lines changed: 0 additions & 70 deletions
This file was deleted.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby.internal.mcp;
7+
8+
import org.jspecify.annotations.NonNull;
9+
import org.jspecify.annotations.Nullable;
10+
import org.slf4j.LoggerFactory;
11+
12+
import io.jooby.Jooby;
13+
import io.jooby.SneakyThrows;
14+
import io.jooby.StatusCode;
15+
import io.jooby.mcp.McpChain;
16+
import io.jooby.mcp.McpInvoker;
17+
import io.jooby.mcp.McpOperation;
18+
import io.modelcontextprotocol.common.McpTransportContext;
19+
import io.modelcontextprotocol.server.McpSyncServerExchange;
20+
import io.modelcontextprotocol.spec.McpError;
21+
import io.modelcontextprotocol.spec.McpSchema;
22+
23+
public class McpExecutor implements McpInvoker {
24+
private final Jooby application;
25+
26+
public McpExecutor(Jooby application) {
27+
this.application = application;
28+
}
29+
30+
@SuppressWarnings("unchecked")
31+
public @NonNull Object invoke(
32+
@Nullable McpSyncServerExchange exchange,
33+
@NonNull McpTransportContext transportContext,
34+
@NonNull McpOperation operation,
35+
@NonNull McpChain next) {
36+
try {
37+
return next.proceed(exchange, transportContext, operation);
38+
} catch (Throwable cause) {
39+
operation.exception(cause);
40+
log(operation, cause);
41+
if (SneakyThrows.isFatal(cause)) {
42+
throw SneakyThrows.propagate(cause);
43+
}
44+
var code = toMcpErrorCode(cause);
45+
if (operation.isTool()) {
46+
// Tool error
47+
var errorMessage =
48+
cause.getMessage() != null ? cause.getMessage() : "Unknown error occurred";
49+
var textContent = new McpSchema.TextContent(errorMessage);
50+
return McpSchema.CallToolResult.builder().addContent(textContent).isError(true).build();
51+
}
52+
if (cause instanceof McpError mcpError) {
53+
throw mcpError;
54+
} else {
55+
throw new McpError(
56+
new McpSchema.JSONRPCResponse.JSONRPCError(code, cause.getMessage(), null));
57+
}
58+
}
59+
}
60+
61+
private void log(McpOperation operation, Throwable cause) {
62+
var log = LoggerFactory.getLogger(operation.getClassName());
63+
var code = toMcpErrorCode(cause);
64+
if (isServerError(code)) {
65+
log.error("execution of {} resulted in exception", operation.getId(), cause);
66+
} else {
67+
log.debug("execution of {} resulted in exception", operation.getId(), cause);
68+
}
69+
}
70+
71+
static boolean isServerError(int code) {
72+
// -32603 is Internal Error. Custom server errors usually fall outside the -32600 to -32699
73+
// reserved range.
74+
return code == McpSchema.ErrorCodes.INTERNAL_ERROR || code < -32700;
75+
}
76+
77+
private int toMcpErrorCode(Throwable cause) {
78+
if (cause instanceof McpError mcpError && mcpError.getJsonRpcError() != null) {
79+
return mcpError.getJsonRpcError().code();
80+
}
81+
var statusCode = application.getRouter().errorCode(cause);
82+
return switch (statusCode.value()) {
83+
case StatusCode.BAD_REQUEST_CODE, StatusCode.CONFLICT_CODE ->
84+
McpSchema.ErrorCodes.INVALID_PARAMS;
85+
case StatusCode.NOT_FOUND_CODE -> McpSchema.ErrorCodes.RESOURCE_NOT_FOUND;
86+
87+
default -> McpSchema.ErrorCodes.INTERNAL_ERROR;
88+
};
89+
}
90+
}

modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInspectorModule.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,6 @@ public McpInspectorModule defaultServer(String mcpServerName) {
115115
@Override
116116
public void install(Jooby app) {
117117
this.indexHtml = buildIndexHtml();
118-
this.mcpSrvConfig = resolveMcpServerConfig(app);
119118

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

@@ -128,6 +127,11 @@ public void install(Jooby app) {
128127
var configJson = buildConfigJson(mcpSrvConfig, location);
129128
return ctx.setResponseType(MediaType.json).render(configJson);
130129
});
130+
131+
app.onStarting(
132+
() -> {
133+
this.mcpSrvConfig = resolveMcpServerConfig(app);
134+
});
131135
}
132136

133137
private String buildIndexHtml() {

modules/jooby-mcp/src/main/java/io/jooby/mcp/McpModule.java

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,13 @@
2020
import io.jooby.Jooby;
2121
import io.jooby.ServiceKey;
2222
import io.jooby.exception.StartupException;
23-
import io.jooby.internal.mcp.McpDefaultInvoker;
23+
import io.jooby.internal.mcp.McpExecutor;
2424
import io.jooby.internal.mcp.McpServerConfig;
2525
import io.jooby.internal.mcp.transport.SseTransportProvider;
2626
import io.jooby.internal.mcp.transport.StatelessTransportProvider;
2727
import io.jooby.internal.mcp.transport.StreamableTransportProvider;
2828
import io.jooby.internal.mcp.transport.WebSocketTransportProvider;
29+
import io.jooby.mcp.instrumentation.OtelMcpTracing;
2930
import io.modelcontextprotocol.common.McpTransportContext;
3031
import io.modelcontextprotocol.json.McpJsonMapper;
3132
import io.modelcontextprotocol.server.*;
@@ -153,8 +154,9 @@ public class McpModule implements Extension {
153154
private final List<McpService> mcpServices = new ArrayList<>();
154155

155156
private @Nullable McpInvoker invoker;
157+
private @Nullable OtelMcpTracing head;
156158

157-
private Boolean generateOutputSchema = null;
159+
private @Nullable Boolean generateOutputSchema;
158160

159161
/**
160162
* Creates a new MCP module initialized with the provided generated services.
@@ -200,10 +202,15 @@ public McpModule transport(Transport transport) {
200202
* @return This module instance for method chaining.
201203
*/
202204
public McpModule invoker(McpInvoker invoker) {
203-
if (this.invoker != null) {
204-
this.invoker = invoker.then(this.invoker);
205+
if (invoker instanceof OtelMcpTracing otel) {
206+
// otel goes first:
207+
this.head = otel;
205208
} else {
206-
this.invoker = invoker;
209+
if (this.invoker != null) {
210+
this.invoker = invoker.then(this.invoker);
211+
} else {
212+
this.invoker = invoker;
213+
}
207214
}
208215
return this;
209216
}
@@ -229,9 +236,14 @@ public void install(Jooby app) {
229236
? app.getConfig().getBoolean("mcp.generateOutputSchema")
230237
: Optional.ofNullable(this.generateOutputSchema).orElse(Boolean.FALSE);
231238
// invoker
232-
McpInvoker pipeline = new McpDefaultInvoker(app);
239+
McpInvoker pipeline = new McpExecutor(app);
240+
// Otel tracing goes first:
241+
if (head != null) {
242+
invoker = invoker == null ? head : head.then(invoker);
243+
}
244+
// Default invoker:
233245
if (this.invoker != null) {
234-
pipeline = pipeline.then(this.invoker);
246+
pipeline = this.invoker.then(pipeline);
235247
}
236248
services.put(McpInvoker.class, pipeline);
237249
// Group services by server

0 commit comments

Comments
 (0)