Skip to content

Commit bfc4529

Browse files
authored
Implement McpTrafficListener option for protocol level debugging (#238)
Signed-off-by: David Kornel <kornys@outlook.com>
1 parent f589f4d commit bfc4529

5 files changed

Lines changed: 261 additions & 0 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ io.streamshub.mcp.common.
9090
│ LogRedactionFilter, RateLimitFilter, ResponseSizeLimitFilter,
9191
│ MetricsFilter (Micrometer tool call metrics), RateCategory,
9292
│ JsonNodeSanitizer (recursive text-node transformer for redaction)
93+
├── observability/ → McpTrafficLogger (MCP protocol traffic listener for DEBUG logging)
9394
├── readiness/ → KubernetesConnectionReadinessCheck (health check for kube API)
9495
├── service/ → KubernetesResourceService, PodsService, DeploymentService, CompletionHelper,
9596
│ │ CompletionCache (TTL-based cache for completion results),

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
- **System tests** for Elasticsearch log provider covering log collection, field mapping, time window queries, and error handling
1616
- **Thanos and VictoriaMetrics compatibility documentation** — the existing Prometheus metrics provider works with Thanos Querier and VictoriaMetrics without code changes
1717
- **Resource template cache control** — All 6 resource templates now include cache control hints with a 30-second TTL and PUBLIC scope, allowing MCP clients to cache Kubernetes resource state and reduce redundant API calls. TTL is configurable via `mcp.resource-template.cache-ttl-seconds` (default: 30).
18+
- **MCP protocol traffic logger** — Added `McpTrafficLogger` implementing `McpTrafficListener` (new in MCP 2.0) for DEBUG-level logging of all inbound/outbound MCP messages. Enable with `quarkus.log.category."io.streamshub.mcp.common.observability".level=DEBUG` in `application.properties`.
1819

1920
### Changed
2021

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*
2+
* Copyright StreamsHub authors.
3+
* License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html).
4+
*/
5+
package io.streamshub.mcp.common.observability;
6+
7+
import io.quarkiverse.mcp.server.McpConnection;
8+
import io.quarkiverse.mcp.server.McpTrafficListener;
9+
import io.quarkiverse.mcp.server.RawMessage;
10+
import io.quarkiverse.mcp.server.RequestId;
11+
import jakarta.enterprise.context.ApplicationScoped;
12+
import org.jboss.logging.Logger;
13+
14+
/**
15+
* MCP protocol traffic listener for observability.
16+
*
17+
* <p>Logs all inbound and outbound MCP messages at DEBUG level, capturing:
18+
* <ul>
19+
* <li>Direction (received/sent)</li>
20+
* <li>Connection ID</li>
21+
* <li>JSON-RPC method name (requests/notifications)</li>
22+
* <li>Request ID (requests/responses)</li>
23+
* <li>Full message content (formatted JSON)</li>
24+
* </ul>
25+
*
26+
* <p>Unlike the built-in {@code TrafficLogger} (which respects
27+
* {@code quarkus.mcp.server.traffic-logging.enabled}), this listener is
28+
* always invoked. Actual logging is gated by the JBoss Logger DEBUG level,
29+
* allowing it to be enabled/disabled via logging configuration without
30+
* server restarts.</p>
31+
*
32+
* <p>Registered automatically as a CDI bean. No explicit configuration needed.</p>
33+
*/
34+
@ApplicationScoped
35+
public class McpTrafficLogger implements McpTrafficListener {
36+
37+
private static final Logger LOG = Logger.getLogger(McpTrafficLogger.class);
38+
39+
McpTrafficLogger() {
40+
}
41+
42+
/**
43+
* {@inheritDoc}
44+
*
45+
* <p>Always returns {@code true}. Actual logging is controlled by
46+
* the JBoss Logger DEBUG level.</p>
47+
*/
48+
@Override
49+
public boolean isEnabled() {
50+
return true;
51+
}
52+
53+
/**
54+
* {@inheritDoc}
55+
*
56+
* <p>Logs received messages at DEBUG level with method name, request ID,
57+
* connection ID, and formatted message content.</p>
58+
*/
59+
@Override
60+
public void onMessageReceived(final RawMessage message, final McpConnection connection) {
61+
if (!LOG.isDebugEnabled()) {
62+
return;
63+
}
64+
65+
String method = message.method();
66+
RequestId id = message.id();
67+
String messageStr = formatMessage(message);
68+
69+
LOG.debugf("MCP RECEIVED [conn=%s, method=%s, id=%s]:%n%s",
70+
connection.id(), method, id, messageStr);
71+
}
72+
73+
/**
74+
* {@inheritDoc}
75+
*
76+
* <p>Logs sent messages at DEBUG level with method name, request ID,
77+
* connection ID, and formatted message content.</p>
78+
*/
79+
@Override
80+
public void onMessageSent(final RawMessage message, final McpConnection connection) {
81+
if (!LOG.isDebugEnabled()) {
82+
return;
83+
}
84+
85+
String method = message.method();
86+
RequestId id = message.id();
87+
String messageStr = formatMessage(message);
88+
89+
LOG.debugf("MCP SENT [conn=%s, method=%s, id=%s]:%n%s",
90+
connection.id(), method, id, messageStr);
91+
}
92+
93+
private String formatMessage(final RawMessage message) {
94+
String pretty = message.asPrettyString();
95+
return (pretty != null && !pretty.isBlank()) ? pretty : "n/a";
96+
}
97+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/*
2+
* Copyright StreamsHub authors.
3+
* License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html).
4+
*/
5+
package io.streamshub.mcp.common.observability;
6+
7+
import io.quarkiverse.mcp.server.McpConnection;
8+
import io.quarkiverse.mcp.server.RawMessage;
9+
import io.quarkiverse.mcp.server.RequestId;
10+
import org.junit.jupiter.api.BeforeEach;
11+
import org.junit.jupiter.api.Test;
12+
import org.mockito.Mockito;
13+
14+
import static org.junit.jupiter.api.Assertions.assertTrue;
15+
import static org.mockito.Mockito.verify;
16+
import static org.mockito.Mockito.when;
17+
18+
/**
19+
* Unit tests for {@link McpTrafficLogger}.
20+
*/
21+
class McpTrafficLoggerTest {
22+
23+
private McpTrafficLogger logger;
24+
private RawMessage message;
25+
private McpConnection connection;
26+
27+
McpTrafficLoggerTest() {
28+
}
29+
30+
@BeforeEach
31+
void setUp() {
32+
logger = new McpTrafficLogger();
33+
message = Mockito.mock(RawMessage.class);
34+
connection = Mockito.mock(McpConnection.class);
35+
}
36+
37+
@Test
38+
void testIsEnabledReturnsTrue() {
39+
assertTrue(logger.isEnabled(), "McpTrafficLogger should always be enabled");
40+
}
41+
42+
@Test
43+
void testOnMessageReceivedWithRequestMessage() {
44+
RequestId requestId = Mockito.mock(RequestId.class);
45+
when(requestId.toString()).thenReturn("123");
46+
when(message.method()).thenReturn("tools/call");
47+
when(message.id()).thenReturn(requestId);
48+
when(message.asPrettyString()).thenReturn("{\n \"method\": \"tools/call\"\n}");
49+
when(connection.id()).thenReturn("conn-abc");
50+
51+
logger.onMessageReceived(message, connection);
52+
53+
// Verify the logger accessed message properties
54+
verify(message).method();
55+
verify(message).id();
56+
verify(message).asPrettyString();
57+
verify(connection).id();
58+
}
59+
60+
@Test
61+
void testOnMessageReceivedWithNotification() {
62+
when(message.method()).thenReturn("notifications/message");
63+
when(message.id()).thenReturn(null); // Notifications have no ID
64+
when(message.asPrettyString()).thenReturn("{\n \"method\": \"notifications/message\"\n}");
65+
when(connection.id()).thenReturn("conn-xyz");
66+
67+
logger.onMessageReceived(message, connection);
68+
69+
// Verify the logger accessed message properties
70+
verify(message).method();
71+
verify(message).id();
72+
verify(message).asPrettyString();
73+
verify(connection).id();
74+
}
75+
76+
@Test
77+
void testOnMessageReceivedWithResponse() {
78+
RequestId requestId = Mockito.mock(RequestId.class);
79+
when(requestId.toString()).thenReturn("456");
80+
when(message.method()).thenReturn(null); // Responses have no method
81+
when(message.id()).thenReturn(requestId);
82+
when(message.asPrettyString()).thenReturn("{\n \"result\": {...}\n}");
83+
when(connection.id()).thenReturn("conn-def");
84+
85+
logger.onMessageReceived(message, connection);
86+
87+
// Verify the logger accessed message properties
88+
verify(message).method();
89+
verify(message).id();
90+
verify(message).asPrettyString();
91+
verify(connection).id();
92+
}
93+
94+
@Test
95+
void testOnMessageSentWithRequestMessage() {
96+
RequestId requestId = Mockito.mock(RequestId.class);
97+
when(requestId.toString()).thenReturn("789");
98+
when(message.method()).thenReturn("tools/list");
99+
when(message.id()).thenReturn(requestId);
100+
when(message.asPrettyString()).thenReturn("{\n \"method\": \"tools/list\"\n}");
101+
when(connection.id()).thenReturn("conn-ghi");
102+
103+
logger.onMessageSent(message, connection);
104+
105+
// Verify the logger accessed message properties
106+
verify(message).method();
107+
verify(message).id();
108+
verify(message).asPrettyString();
109+
verify(connection).id();
110+
}
111+
112+
@Test
113+
void testOnMessageSentWithNullPrettyString() {
114+
when(message.method()).thenReturn("tools/call");
115+
when(message.id()).thenReturn(null);
116+
when(message.asPrettyString()).thenReturn(null);
117+
when(connection.id()).thenReturn("conn-jkl");
118+
119+
logger.onMessageSent(message, connection);
120+
121+
// Verify null is handled - formatMessage still called asPrettyString()
122+
verify(message).method();
123+
verify(message).id();
124+
verify(message).asPrettyString();
125+
verify(connection).id();
126+
}
127+
128+
@Test
129+
void testOnMessageSentWithEmptyPrettyString() {
130+
when(message.method()).thenReturn("tools/call");
131+
when(message.id()).thenReturn(null);
132+
when(message.asPrettyString()).thenReturn("");
133+
when(connection.id()).thenReturn("conn-mno");
134+
135+
logger.onMessageSent(message, connection);
136+
137+
// Verify empty string is handled gracefully
138+
verify(message).method();
139+
verify(message).id();
140+
verify(message).asPrettyString();
141+
verify(connection).id();
142+
}
143+
144+
@Test
145+
void testOnMessageReceivedWithBlankPrettyString() {
146+
when(message.method()).thenReturn("initialize");
147+
when(message.id()).thenReturn(null);
148+
when(message.asPrettyString()).thenReturn(" ");
149+
when(connection.id()).thenReturn("conn-pqr");
150+
151+
logger.onMessageReceived(message, connection);
152+
153+
// Verify blank string is handled gracefully (falls back to "n/a")
154+
verify(message).method();
155+
verify(message).id();
156+
verify(message).asPrettyString();
157+
verify(connection).id();
158+
}
159+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
quarkus.arc.remove-unused-beans=false
22
quarkus.kubernetes-client.namespace=test
3+
4+
# Enable DEBUG logging for McpTrafficLogger tests
5+
quarkus.log.category."io.streamshub.mcp.common.observability".level=DEBUG

0 commit comments

Comments
 (0)