Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,12 @@ public void start() {
.capabilities(McpSchema.ServerCapabilities.builder().tools(true).logging().build())
.tools(filterForEnabledTools(supportedTools).stream().map(this::toStdioSpec).toArray(McpServerFeatures.SyncToolSpecification[]::new))
.build();
LOG.setNotifier(notification -> transportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_MESSAGE, notification)
.subscribe(v -> {
// success: nothing to do, already written to file
}, e -> {
// swallow: notification the SDK already logs dispatch failure
}));
}

Runtime.getRuntime().addShutdownHook(new Thread(this::shutdown));
Expand Down Expand Up @@ -629,6 +635,7 @@ public void shutdown() {
shutdownMcpServer();
shutdownAnalytics();
shutdownBackend();
LOG.clearNotifier();
}

private void shutdownAnalytics() {
Expand Down
50 changes: 40 additions & 10 deletions src/main/java/org/sonarsource/sonarqube/mcp/log/McpLogger.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,32 @@
*/
package org.sonarsource.sonarqube.mcp.log;

import io.modelcontextprotocol.spec.McpSchema;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.Objects;
import java.util.function.Consumer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* MCP-specific logger that outputs to both:
* - STDERR: for MCP clients (the MCP protocol uses STDERR for diagnostic logs, STDOUT is reserved for JSON-RPC messages)
* MCP-specific logger that outputs to:
* - Log file: via SLF4J/Logback for persistence and debugging
* - MCP client: as {@code notifications/message} (per the MCP logging spec) when a notifier is configured.
* Outside of a session (e.g. during startup, before the MCP server is built) the notifier is a no-op
* and only SLF4J file logging happens.
*/
public class McpLogger {

private static final Logger LOG = LoggerFactory.getLogger(McpLogger.class);
private static final McpLogger INSTANCE = new McpLogger();
private static final String SONARQUBE_DEBUG_ENABLED = "SONARQUBE_DEBUG_ENABLED";
private static final String LOGGER_NAME = "sonarqube-mcp-server";
private static final Consumer<McpSchema.LoggingMessageNotification> NO_OP = notification -> {
// no-op
};

private Consumer<McpSchema.LoggingMessageNotification> notifier = NO_OP;

public static McpLogger getInstance() {
return INSTANCE;
Expand All @@ -46,36 +59,53 @@ private static boolean resolveDebugEnabled() {
return "true".equalsIgnoreCase(System.getProperty(SONARQUBE_DEBUG_ENABLED));
}

public void setNotifier(Consumer<McpSchema.LoggingMessageNotification> notifier) {
this.notifier = Objects.requireNonNull(notifier);
}

public void clearNotifier() {
this.notifier = NO_OP;
}

public void info(String message) {
LOG.info(message);
logToStderr("INFO", message);
notify(McpSchema.LoggingLevel.INFO, message);
}

public void debug(String message) {
if (isDebugEnabled()) {
LOG.debug(message);
logToStderr("DEBUG", message);
notify(McpSchema.LoggingLevel.DEBUG, message);
}
}

public void warn(String message) {
LOG.warn(message);
logToStderr("WARN", message);
notify(McpSchema.LoggingLevel.WARNING, message);
}

public void error(String message, Throwable throwable) {
LOG.error(message, throwable);
logToStderr("ERROR", message);
throwable.printStackTrace(System.err);
notify(McpSchema.LoggingLevel.ERROR, message + System.lineSeparator() + stackTraceOf(throwable));
}

public void error(String message) {
LOG.error(message);
logToStderr("ERROR", message);
notify(McpSchema.LoggingLevel.ERROR, message);
}

private void notify(McpSchema.LoggingLevel level, String message) {
try {
notifier.accept(new McpSchema.LoggingMessageNotification(level, LOGGER_NAME, message, null));
} catch (Exception e) {
LOG.warn("Failed to dispatch MCP log notification", e);
}
}

private static void logToStderr(String level, String message) {
System.err.println(level + " SonarQube MCP Server - " + message);
private static String stackTraceOf(Throwable throwable) {
var writer = new StringWriter();
throwable.printStackTrace(new PrintWriter(writer));
return writer.toString();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,17 @@
package org.sonarsource.sonarqube.mcp;

import io.modelcontextprotocol.common.McpTransportContext;
import java.io.ByteArrayOutputStream;
import io.modelcontextprotocol.spec.McpSchema;
import java.io.IOException;
import java.io.PrintStream;
import java.net.ServerSocket;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.stream.Collectors;
import org.sonarsource.sonarqube.mcp.harness.SonarQubeMcpServerTest;
import org.sonarsource.sonarqube.mcp.harness.SonarQubeMcpServerTestHarness;
import org.sonarsource.sonarqube.mcp.log.McpLogger;
import org.sonarsource.sonarqube.mcp.tools.proxied.ProxiedMcpTool;
import org.sonarsource.sonarqube.mcp.transport.HttpServerTransportProvider;
import org.sonarsource.sonarqube.mcp.transport.StdioServerTransportProvider;
Expand Down Expand Up @@ -58,9 +60,7 @@ void get_should_return_server_api_in_stdio_mode(SonarQubeMcpServerTestHarness ha
@SonarQubeMcpServerTest
void should_log_sanitized_config_when_elevated_debug_enabled(SonarQubeMcpServerTestHarness harness) {
System.setProperty("SONARQUBE_DEBUG_ENABLED", "true");
var originalErr = System.err;
var errBuffer = new ByteArrayOutputStream();
System.setErr(new PrintStream(errBuffer, true, StandardCharsets.UTF_8));
List<McpSchema.LoggingMessageNotification> capturedNotifications = new CopyOnWriteArrayList<>();

try {
var environment = createStdioEnvironment(harness.getMockSonarQubeServer().baseUrl());
Expand All @@ -71,6 +71,9 @@ void should_log_sanitized_config_when_elevated_debug_enabled(SonarQubeMcpServerT
null,
environment);
server.start();
// Override the server-installed notifier with a capturing one so we can assert on the
// notifications/message payloads emitted during the asynchronous startup logging.
McpLogger.getInstance().setNotifier(capturedNotifications::add);
var configuration = server.getMcpConfiguration();

assertThat(System.getProperty("os.name")).isNotNull().isNotEmpty();
Expand All @@ -81,10 +84,12 @@ void should_log_sanitized_config_when_elevated_debug_enabled(SonarQubeMcpServerT
assertThat(configuration.getLogFilePath().toAbsolutePath().toString()).isNotNull().isNotEmpty();

await().atMost(2, SECONDS).untilAsserted(() -> {
var stderrOutput = errBuffer.toString(StandardCharsets.UTF_8);
var joined = capturedNotifications.stream()
.map(McpSchema.LoggingMessageNotification::data)
.collect(Collectors.joining("\n"));
var proxySelector = java.net.ProxySelector.getDefault();
var proxySelectorName = proxySelector != null ? proxySelector.getClass().getName() : "none";
assertThat(stderrOutput)
assertThat(joined)
.contains("SSL/TLS - OS: " + System.getProperty("os.name"))
.contains("SSL/TLS configured - protocol: TLS")
.contains("Proxy selector: " + proxySelectorName)
Expand All @@ -102,7 +107,7 @@ void should_log_sanitized_config_when_elevated_debug_enabled(SonarQubeMcpServerT
server.shutdown();
} finally {
System.clearProperty("SONARQUBE_DEBUG_ENABLED");
System.setErr(originalErr);
McpLogger.getInstance().clearNotifier();
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,23 @@
package org.sonarsource.sonarqube.mcp.http;

import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
import io.modelcontextprotocol.spec.McpSchema;
import java.io.IOException;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.net.ProxySelector;
import java.net.SocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.stream.Collectors;
import org.apache.hc.core5.http.HttpStatus;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.sonarsource.sonarqube.mcp.log.McpLogger;

import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.get;
Expand All @@ -50,20 +51,14 @@ class HttpClientProxyTests {
.build();

private ProxySelector originalProxySelector;
private PrintStream originalErr;
private ByteArrayOutputStream errBuffer;

@AfterEach
void tearDown() {
// Restore original proxy selector
if (originalProxySelector != null) {
ProxySelector.setDefault(originalProxySelector);
}
if (originalErr != null) {
System.setErr(originalErr);
originalErr = null;
errBuffer = null;
}
McpLogger.getInstance().clearNotifier();
// Clear proxy system properties
System.clearProperty("http.proxyHost");
System.clearProperty("http.proxyPort");
Expand All @@ -77,6 +72,18 @@ void tearDown() {
System.clearProperty("SONARQUBE_DEBUG_ENABLED");
}

private static List<McpSchema.LoggingMessageNotification> captureLogNotifications() {
var captured = new CopyOnWriteArrayList<McpSchema.LoggingMessageNotification>();
McpLogger.getInstance().setNotifier(captured::add);
return captured;
}

private static String joinedLogData(List<McpSchema.LoggingMessageNotification> notifications) {
return notifications.stream()
.map(McpSchema.LoggingMessageNotification::data)
.collect(Collectors.joining("\n"));
}

@Test
void should_invoke_proxy_selector_when_making_requests() {
var trackingProxySelector = new TrackingProxySelector();
Expand Down Expand Up @@ -163,15 +170,13 @@ void should_log_proxy_settings_when_elevated_debug_enabled() {
System.setProperty("https.proxyHost", "proxy.local");
System.setProperty("https.proxyPort", "3129");

originalErr = System.err;
errBuffer = new ByteArrayOutputStream();
System.setErr(new PrintStream(errBuffer, true, StandardCharsets.UTF_8));
var captured = captureLogNotifications();

var provider = new HttpClientProvider(USER_AGENT);
provider.logConnectionSettings();

var stderrOutput = errBuffer.toString(StandardCharsets.UTF_8);
assertThat(stderrOutput)
var output = joinedLogData(captured);
assertThat(output)
.contains("SSL/TLS - OS: ")
.contains("SSL/TLS configured - protocol: ")
.contains("Proxy selector: ")
Expand All @@ -187,15 +192,13 @@ void should_log_socks_proxy_settings_when_elevated_debug_enabled() {
System.setProperty("socksProxyHost", "socks.local");
System.setProperty("socksProxyPort", "1080");

originalErr = System.err;
errBuffer = new ByteArrayOutputStream();
System.setErr(new PrintStream(errBuffer, true, StandardCharsets.UTF_8));
var captured = captureLogNotifications();

var provider = new HttpClientProvider(USER_AGENT);
provider.logConnectionSettings();

var stderrOutput = errBuffer.toString(StandardCharsets.UTF_8);
assertThat(stderrOutput)
var output = joinedLogData(captured);
assertThat(output)
.contains("SOCKS proxy: socks.local:1080")
.doesNotContain("No proxy system properties configured");
}
Expand All @@ -204,15 +207,13 @@ void should_log_socks_proxy_settings_when_elevated_debug_enabled() {
void should_log_no_proxy_when_no_proxy_properties_set() {
System.setProperty("SONARQUBE_DEBUG_ENABLED", "true");

originalErr = System.err;
errBuffer = new ByteArrayOutputStream();
System.setErr(new PrintStream(errBuffer, true, StandardCharsets.UTF_8));
var captured = captureLogNotifications();

var provider = new HttpClientProvider(USER_AGENT);
provider.logConnectionSettings();

var stderrOutput = errBuffer.toString(StandardCharsets.UTF_8);
assertThat(stderrOutput).contains("No proxy system properties configured");
var output = joinedLogData(captured);
assertThat(output).contains("No proxy system properties configured");
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@
package org.sonarsource.sonarqube.mcp.serverapi;

import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import io.modelcontextprotocol.spec.McpSchema;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.apache.hc.core5.http.HttpStatus;
import org.junit.jupiter.api.AfterEach;
Expand All @@ -31,6 +32,7 @@
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.sonarsource.sonarqube.mcp.http.HttpClientProvider;
import org.sonarsource.sonarqube.mcp.log.McpLogger;
import org.sonarsource.sonarqube.mcp.serverapi.exception.ForbiddenException;
import org.sonarsource.sonarqube.mcp.serverapi.exception.NotFoundException;
import org.sonarsource.sonarqube.mcp.serverapi.exception.ServerInternalErrorException;
Expand All @@ -50,8 +52,6 @@ class ServerApiTests {

private static final String USER_AGENT = "SonarQube MCP tests";
private ServerApiHelper serverApiHelper;
private PrintStream originalErr;
private ByteArrayOutputStream errBuffer;

@RegisterExtension
static WireMockExtension sonarqubeMock = WireMockExtension.newInstance()
Expand All @@ -69,11 +69,7 @@ void init() {
@AfterEach
void tearDown() {
System.clearProperty("SONARQUBE_DEBUG_ENABLED");
if (originalErr != null) {
System.setErr(originalErr);
originalErr = null;
errBuffer = null;
}
McpLogger.getInstance().clearNotifier();
}

static Stream<Arguments> getErrorResponses() {
Expand Down Expand Up @@ -121,16 +117,17 @@ void it_should_parse_error_message_from_body(String responseBody, String message
@Test
void it_should_log_error_response_code() {
System.setProperty("SONARQUBE_DEBUG_ENABLED", "true");
originalErr = System.err;
errBuffer = new ByteArrayOutputStream();
System.setErr(new PrintStream(errBuffer, true, StandardCharsets.UTF_8));
List<McpSchema.LoggingMessageNotification> capturedNotifications = new CopyOnWriteArrayList<>();
McpLogger.getInstance().setNotifier(capturedNotifications::add);

sonarqubeMock.stubFor(get("/test").willReturn(jsonResponse("{\"errors\": [{\"msg\": \"Missing permission\",\"code\":\"insufficient_privileges\"}]}", HttpStatus.SC_FORBIDDEN)));

assertThrows(ForbiddenException.class, () -> serverApiHelper.get("/test"));

var stderrOutput = errBuffer.toString(StandardCharsets.UTF_8);
assertThat(stderrOutput)
var joined = capturedNotifications.stream()
.map(McpSchema.LoggingMessageNotification::data)
.collect(Collectors.joining("\n"));
assertThat(joined)
.contains("HTTP error - URL: " + sonarqubeMock.baseUrl() + "/test")
.contains("status: 403");
}
Expand Down
Loading