Skip to content

Commit 07e12ea

Browse files
MCP-3 Support logging from the MCP server
1 parent 51419e1 commit 07e12ea

File tree

8 files changed

+158
-11
lines changed

8 files changed

+158
-11
lines changed

build.gradle.kts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ license {
6262
)
6363
)
6464
excludes(
65-
listOf("**/*.jar", "**/*.png", "**/README")
65+
listOf("**/*.jar", "**/*.png", "**/README", "**/logback.xml")
6666
)
6767
strictCheck = true
6868
}
@@ -76,6 +76,8 @@ configurations {
7676
create("omnisharp")
7777
}
7878

79+
val mockitoAgent = configurations.create("mockitoAgent")
80+
7981
dependencies {
8082
implementation(libs.mcp.server)
8183
implementation(libs.sonarlint.java.client.utils)
@@ -84,13 +86,15 @@ dependencies {
8486
implementation(libs.commons.langs3)
8587
implementation(libs.commons.text)
8688
implementation(libs.sslcontext.kickstart)
89+
runtimeOnly(libs.logback.classic)
8790
testImplementation(platform(libs.junit.bom))
8891
testImplementation(libs.junit.jupiter)
8992
testImplementation(libs.mockito.core)
9093
testImplementation(libs.assertj)
9194
testImplementation(libs.awaitility)
9295
testImplementation(libs.wiremock)
9396
testRuntimeOnly(libs.junit.launcher)
97+
mockitoAgent(libs.mockito.core) { isTransitive = false }
9498
"sqplugins"(libs.bundles.sonar.analyzers)
9599
if (artifactoryUsername.isNotEmpty() && artifactoryPassword.isNotEmpty()) {
96100
"sqplugins"(libs.sonar.cfamily)
@@ -179,6 +183,7 @@ tasks {
179183
systemProperty("TELEMETRY_DISABLED", "true")
180184
systemProperty("sonar.mcp.server.version", project.version)
181185
doNotTrackState("Tests should always run")
186+
jvmArgs("-javaagent:${mockitoAgent.asPath}")
182187
}
183188

184189
register("preparePlugins") {

gradle/libs.versions.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ mcp-server = "0.9.0"
2626
commons-lang3 = "3.17.0"
2727
commons-text = "1.13.0"
2828
sslcontext-kickstart = "9.1.0"
29+
logback = "1.5.17"
2930

3031
junit = "5.12.2"
3132
junit-launcher = "1.12.2"
@@ -60,6 +61,9 @@ mcp-server = { module = "io.modelcontextprotocol.sdk:mcp", version.ref = "mcp-se
6061
commons-langs3 = { module = "org.apache.commons:commons-lang3", version.ref = "commons-lang3" }
6162
commons-text = { module = "org.apache.commons:commons-text", version.ref = "commons-text" }
6263
sslcontext-kickstart = { module = "io.github.hakky54:sslcontext-kickstart", version.ref = "sslcontext-kickstart" }
64+
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
65+
66+
6367
junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
6468
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
6569
junit-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junit-launcher" }

src/main/java/org/sonar/mcp/SonarMcpServer.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ public void start() {
7171
.capabilities(McpSchema.ServerCapabilities.builder().tools(true).logging().build())
7272
.tools(supportedTools.stream().map(this::toSpec).toArray(McpServerFeatures.SyncToolSpecification[]::new))
7373
.build();
74-
74+
LOG.setOutput(syncServer);
7575
Runtime.getRuntime().addShutdownHook(new Thread(() -> shutdown(syncServer, backendService)));
7676
}
7777

@@ -104,6 +104,7 @@ private static void shutdown(McpSyncServer syncServer, BackendService backendSer
104104
} catch (Exception e) {
105105
LOG.error("Error shutting down MCP backend", e);
106106
}
107+
McpLogger.getInstance().setOutput(null);
107108
}
108109

109110
}

src/main/java/org/sonar/mcp/log/McpLogger.java

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@
1919
*/
2020
package org.sonar.mcp.log;
2121

22+
import io.modelcontextprotocol.server.McpSyncServer;
23+
import io.modelcontextprotocol.spec.McpSchema;
24+
import jakarta.annotation.Nullable;
25+
import java.io.PrintWriter;
26+
import java.io.StringWriter;
2227
import org.sonarsource.sonarlint.core.rpc.protocol.client.log.LogLevel;
2328
import org.sonarsource.sonarlint.core.rpc.protocol.client.log.LogParams;
2429

@@ -29,7 +34,17 @@ public static McpLogger getInstance() {
2934
return INSTANCE;
3035
}
3136

37+
private McpSyncServer syncServer;
38+
39+
public void setOutput(@Nullable McpSyncServer syncServer) {
40+
this.syncServer = syncServer;
41+
}
42+
3243
public void log(LogParams params) {
44+
var message = params.getMessage();
45+
if (message != null) {
46+
log(message, params.getLevel());
47+
}
3348
var stackTrace = params.getStackTrace();
3449
if (stackTrace != null) {
3550
log(stackTrace, params.getLevel());
@@ -42,11 +57,32 @@ public void info(String message) {
4257

4358
public void error(String message, Throwable throwable) {
4459
log(message, LogLevel.ERROR);
45-
throwable.printStackTrace();
60+
log(stackTraceToString(throwable), LogLevel.ERROR);
61+
}
62+
63+
private void log(String message, LogLevel level) {
64+
if (syncServer != null) {
65+
// We rely on a deprecated API for now, I opened a discussion in https://github.com/modelcontextprotocol/java-sdk/issues/131
66+
try {
67+
syncServer.loggingNotification(new McpSchema.LoggingMessageNotification(toMcpLevel(level), "sonar-mcp-server", message));
68+
} catch (Exception e) {
69+
// we can't do much
70+
}
71+
}
72+
}
73+
74+
static McpSchema.LoggingLevel toMcpLevel(LogLevel level) {
75+
return switch (level) {
76+
case ERROR -> McpSchema.LoggingLevel.ERROR;
77+
case WARN -> McpSchema.LoggingLevel.WARNING;
78+
case INFO -> McpSchema.LoggingLevel.INFO;
79+
case DEBUG, TRACE -> McpSchema.LoggingLevel.DEBUG;
80+
};
4681
}
4782

48-
private static void log(String message, LogLevel level) {
49-
// will be properly implemented in SLCORE-1345
50-
System.out.println("[" + level.name() + "] " + message);
83+
static String stackTraceToString(Throwable t) {
84+
var stringWriter = new StringWriter();
85+
t.printStackTrace(new PrintWriter(stringWriter));
86+
return stringWriter.toString();
5187
}
5288
}

src/main/resources/logback.xml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?xml version="1.0" encoding="UTF-8" ?>
2+
<!DOCTYPE configuration>
3+
4+
<configuration>
5+
<root level="warn"/>
6+
<logger name="org.springframework" level="warn"/>
7+
<logger name="org.sonarsource.sonarlint.core.plugin.commons.container.PriorityBeanFactory" level="warn"/>
8+
<logger name="nl.altindag.ssl" level="warn"/>
9+
<logger name="jetbrains.exodus" level="warn"/>
10+
<logger name="org.apache.hc" level="warn"/>
11+
<logger name="org.eclipse.lsp4j.jsonrpc" level="error"/>
12+
<logger name="org.eclipse.jgit" level="info"/>
13+
<logger name="org.sonar.scm.git.blame" level="info"/>
14+
<!-- Disable to avoid logs being printed in loop -->
15+
<logger name="io.modelcontextprotocol.spec.McpSchema" level="off" />
16+
<logger name="io.modelcontextprotocol.client.McpAsyncClient" level="off" />
17+
<logger name="io.modelcontextprotocol.spec.McpClientSession" level="off" />
18+
<!-- Disable logback's own logs -->
19+
<statusListener class="ch.qos.logback.core.status.NopStatusListener" />
20+
</configuration>

src/test/java/org/sonar/mcp/harness/InMemoryClientTransport.java

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -220,9 +220,6 @@ private static void log(String message) {
220220
}
221221

222222
private static void log(String message, @Nullable Throwable e) {
223-
System.out.println(message);
224-
if (e != null) {
225-
e.printStackTrace();
226-
}
223+
// no log for now
227224
}
228225
}

src/test/java/org/sonar/mcp/harness/SonarMcpServerTestHarness.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import io.modelcontextprotocol.client.McpClient;
2424
import io.modelcontextprotocol.client.McpSyncClient;
2525
import io.modelcontextprotocol.server.transport.StdioServerTransportProvider;
26+
import io.modelcontextprotocol.spec.McpSchema;
2627
import java.io.IOException;
2728
import java.io.PipedInputStream;
2829
import java.io.PipedOutputStream;
@@ -90,8 +91,10 @@ public McpSyncClient newClient(Map<String, String> overriddenEnv) {
9091
var environment = new HashMap<>(DEFAULT_ENV);
9192
environment.putAll(overriddenEnv);
9293
new SonarMcpServer(new StdioServerTransportProvider(new ObjectMapper(), clientToServerInputStream, serverToClientOutputStream), environment).start();
93-
client = McpClient.sync(new InMemoryClientTransport(serverToClientInputStream, clientToServerOutputStream)).build();
94+
client = McpClient.sync(new InMemoryClientTransport(serverToClientInputStream, clientToServerOutputStream))
95+
.loggingConsumer(System.out::println).build();
9496
client.initialize();
97+
client.setLoggingLevel(McpSchema.LoggingLevel.CRITICAL);
9598
} catch (IOException e) {
9699
throw new RuntimeException(e);
97100
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* Sonar MCP Server
3+
* Copyright (C) 2025 SonarSource
4+
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public
17+
* License along with this program; if not, write to the Free Software
18+
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02
19+
*/
20+
package org.sonar.mcp.log;
21+
22+
import io.modelcontextprotocol.server.McpSyncServer;
23+
import io.modelcontextprotocol.spec.McpSchema;
24+
import java.time.Instant;
25+
import org.junit.jupiter.api.AfterEach;
26+
import org.junit.jupiter.api.Test;
27+
import org.mockito.ArgumentCaptor;
28+
import org.sonarsource.sonarlint.core.rpc.protocol.client.log.LogLevel;
29+
import org.sonarsource.sonarlint.core.rpc.protocol.client.log.LogParams;
30+
31+
import static org.assertj.core.api.Assertions.assertThat;
32+
import static org.mockito.Mockito.mock;
33+
import static org.mockito.Mockito.times;
34+
import static org.mockito.Mockito.verify;
35+
36+
class McpLoggerTest {
37+
38+
@AfterEach
39+
void teardown() {
40+
McpLogger.getInstance().setOutput(null);
41+
}
42+
43+
@Test
44+
void it_should_convert_the_log_level() {
45+
assertThat(McpLogger.toMcpLevel(LogLevel.TRACE)).isEqualTo(McpSchema.LoggingLevel.DEBUG);
46+
assertThat(McpLogger.toMcpLevel(LogLevel.DEBUG)).isEqualTo(McpSchema.LoggingLevel.DEBUG);
47+
assertThat(McpLogger.toMcpLevel(LogLevel.INFO)).isEqualTo(McpSchema.LoggingLevel.INFO);
48+
assertThat(McpLogger.toMcpLevel(LogLevel.WARN)).isEqualTo(McpSchema.LoggingLevel.WARNING);
49+
assertThat(McpLogger.toMcpLevel(LogLevel.ERROR)).isEqualTo(McpSchema.LoggingLevel.ERROR);
50+
}
51+
52+
@Test
53+
void it_should_send_an_rcp_log_to_the_client() {
54+
var logger = McpLogger.getInstance();
55+
var mockServer = mock(McpSyncServer.class);
56+
logger.setOutput(mockServer);
57+
58+
logger.log(new LogParams(LogLevel.DEBUG, "Message", null, "stack\ntrace", Instant.now()));
59+
60+
verify(mockServer).loggingNotification(new McpSchema.LoggingMessageNotification(McpSchema.LoggingLevel.DEBUG, "sonar-mcp-server", "Message"));
61+
verify(mockServer).loggingNotification(new McpSchema.LoggingMessageNotification(McpSchema.LoggingLevel.DEBUG, "sonar-mcp-server", "stack\ntrace"));
62+
}
63+
64+
@Test
65+
void it_should_send_an_error_log_to_the_client() {
66+
var logger = McpLogger.getInstance();
67+
var mockServer = mock(McpSyncServer.class);
68+
logger.setOutput(mockServer);
69+
70+
logger.error("Message", new RuntimeException("kaboom"));
71+
72+
var captor = ArgumentCaptor.forClass(McpSchema.LoggingMessageNotification.class);
73+
verify(mockServer, times(2)).loggingNotification(captor.capture());
74+
var notifications = captor.getAllValues();
75+
assertThat(notifications.getFirst()).isEqualTo(new McpSchema.LoggingMessageNotification(McpSchema.LoggingLevel.ERROR, "sonar-mcp-server", "Message"));
76+
assertThat(notifications.get(1).level()).isEqualTo(McpSchema.LoggingLevel.ERROR);
77+
assertThat(notifications.get(1).logger()).isEqualTo("sonar-mcp-server");
78+
assertThat(notifications.get(1).data()).contains("java.lang.RuntimeException: kaboom\n");
79+
}
80+
81+
}

0 commit comments

Comments
 (0)