Skip to content

Commit ff7c973

Browse files
committed
MCP-198 Immediately start up the MCP and dynamically load tools
1 parent f583f01 commit ff7c973

File tree

6 files changed

+162
-50
lines changed

6 files changed

+162
-50
lines changed

src/main/java/org/sonarsource/sonarqube/mcp/SonarQubeMcpServer.java

Lines changed: 119 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package org.sonarsource.sonarqube.mcp;
1818

1919
import com.fasterxml.jackson.databind.ObjectMapper;
20+
import com.google.common.annotations.VisibleForTesting;
2021
import io.modelcontextprotocol.server.McpServer;
2122
import io.modelcontextprotocol.server.McpServerFeatures;
2223
import io.modelcontextprotocol.server.McpSyncServer;
@@ -28,6 +29,9 @@
2829
import java.util.ArrayList;
2930
import java.util.List;
3031
import java.util.Map;
32+
import java.util.Objects;
33+
import java.util.concurrent.CompletableFuture;
34+
import java.util.concurrent.ExecutionException;
3135
import java.util.function.Function;
3236
import javax.annotation.Nullable;
3337
import org.sonarsource.sonarqube.mcp.bridge.SonarQubeIdeBridgeClient;
@@ -96,6 +100,7 @@ public class SonarQubeMcpServer implements ServerApiProvider {
96100
private McpSyncServer syncServer;
97101
private volatile boolean isShutdown = false;
98102
private boolean logFileLocationLogged;
103+
private final CompletableFuture<Void> initializationFuture = new CompletableFuture<>();
99104

100105
public static void main(String[] args) {
101106
new SonarQubeMcpServer(System.getenv()).start();
@@ -124,31 +129,122 @@ public SonarQubeMcpServer(Map<String, String> environment) {
124129
this.transportProvider = new StdioServerTransportProvider(new ObjectMapper());
125130
}
126131

127-
initializeServices();
132+
initializeBasicServices();
128133
}
129134

130-
private void initializeServices() {
135+
136+
137+
public void start() {
138+
// Start HTTP server if enabled
139+
if (httpServerManager != null) {
140+
LOG.info("Starting HTTP server on " + mcpConfiguration.getHttpHost() + ":" + mcpConfiguration.getHttpPort() + "...");
141+
httpServerManager.startServer().join();
142+
LOG.info("HTTP server started");
143+
}
144+
145+
// Build and start MCP server immediately with NO tools
146+
// Tools will be added dynamically once background initialization completes
147+
Function<Object, McpSyncServer> serverBuilder = provider -> {
148+
var builder = switch (provider) {
149+
case McpServerTransportProvider p -> McpServer.sync(p);
150+
case McpStreamableServerTransportProvider p -> McpServer.sync(p);
151+
default -> throw new IllegalArgumentException("Unsupported transport provider type: " + provider.getClass().getName());
152+
};
153+
return builder
154+
.serverInfo(new McpSchema.Implementation(SONARQUBE_MCP_SERVER_NAME, mcpConfiguration.getAppVersion()))
155+
.instructions("Transform your code quality workflow with SonarQube integration. " +
156+
"Analyze code, monitor project health, investigate issues, and understand quality gates. " +
157+
"Note: Tools are being loaded in the background and will be available shortly.")
158+
.capabilities(McpSchema.ServerCapabilities.builder().tools(true).logging().build())
159+
// Start with no tools - they will be added dynamically after initialization
160+
.build();
161+
};
162+
163+
syncServer = serverBuilder.apply(transportProvider);
164+
165+
Runtime.getRuntime().addShutdownHook(new Thread(this::shutdown));
166+
167+
logInitialization();
168+
169+
// Start background initialization in a separate thread
170+
CompletableFuture.runAsync(this::initializeBackgroundServices)
171+
.exceptionally(ex -> {
172+
LOG.error("Fatal error during background initialization", ex);
173+
return null;
174+
});
175+
}
176+
177+
/**
178+
* Quick initialization - only creates basic services needed for configuration validation.
179+
* Heavy operations (version check, plugin download, backend init) are deferred to background.
180+
*/
181+
private void initializeBasicServices() {
131182
this.backendService = new BackendService(mcpConfiguration);
132183
this.httpClientProvider = new HttpClientProvider(mcpConfiguration.getUserAgent());
133-
this.toolExecutor = new ToolExecutor(backendService);
184+
this.toolExecutor = new ToolExecutor(backendService, initializationFuture);
134185

135-
PluginsSynchronizer pluginsSynchronizer;
186+
// Create ServerApi and SonarQubeVersionChecker early (doesn't make network calls yet)
187+
// This allows initTools() to reference sonarQubeVersionChecker before background init completes
136188
if (mcpConfiguration.isHttpEnabled()) {
137189
var initServerApi = createServerApiWithToken(mcpConfiguration.getSonarQubeToken());
138190
this.sonarQubeVersionChecker = new SonarQubeVersionChecker(initServerApi);
139-
pluginsSynchronizer = new PluginsSynchronizer(initServerApi, mcpConfiguration.getStoragePath());
140191
} else {
141192
this.serverApi = initializeServerApi(mcpConfiguration);
142193
this.sonarQubeVersionChecker = new SonarQubeVersionChecker(serverApi);
143-
pluginsSynchronizer = new PluginsSynchronizer(serverApi, mcpConfiguration.getStoragePath());
144194
}
145-
sonarQubeVersionChecker.failIfSonarQubeServerVersionIsNotSupported();
146-
var analyzers = pluginsSynchronizer.synchronizeAnalyzers();
147-
backendService.initialize(analyzers);
148-
backendService.notifyTransportModeUsed();
195+
}
196+
197+
/**
198+
* Heavy initialization that runs in background after the server has started.
199+
* Downloads analyzers, checks version, initializes the backend, and dynamically loads tools.
200+
*/
201+
private void initializeBackgroundServices() {
202+
try {
203+
sonarQubeVersionChecker.failIfSonarQubeServerVersionIsNotSupported();
204+
205+
PluginsSynchronizer pluginsSynchronizer;
206+
if (mcpConfiguration.isHttpEnabled()) {
207+
var initServerApi = createServerApiWithToken(mcpConfiguration.getSonarQubeToken());
208+
pluginsSynchronizer = new PluginsSynchronizer(initServerApi, mcpConfiguration.getStoragePath());
209+
} else {
210+
pluginsSynchronizer = new PluginsSynchronizer(Objects.requireNonNull(serverApi), mcpConfiguration.getStoragePath());
211+
}
212+
var analyzers = pluginsSynchronizer.synchronizeAnalyzers();
213+
214+
backendService.initialize(analyzers);
215+
backendService.notifyTransportModeUsed();
216+
217+
// Load and register tools dynamically
218+
LOG.info("Loading tools...");
219+
initTools();
220+
notifyToolsLoaded();
149221

150-
LOG.info("Startup initialization completed");
222+
initializationFuture.complete(null);
223+
LOG.info("Background initialization completed successfully");
224+
} catch (Exception e) {
225+
LOG.error("Background initialization failed", e);
226+
initializationFuture.completeExceptionally(e);
227+
throw e;
228+
}
151229
}
230+
231+
/**
232+
* Notifies connected clients about newly loaded tools.
233+
* This may fail if no client is connected yet (common during startup or in tests).
234+
* Failure is acceptable as clients will discover tools via listTools() when they connect.
235+
*/
236+
private void notifyToolsLoaded() {
237+
try {
238+
for (var tool : supportedTools) {
239+
syncServer.addTool(toSpec(tool));
240+
}
241+
syncServer.notifyToolsListChanged();
242+
LOG.info(supportedTools.size() + " tools loaded and registered");
243+
} catch (Exception e) {
244+
LOG.info(supportedTools.size() + " tools loaded (client notification deferred)");
245+
}
246+
}
247+
152248
private void initTools() {
153249
var allTools = new ArrayList<Tool>();
154250

@@ -213,37 +309,6 @@ private void initTools() {
213309
LOG.info("Loaded " + this.supportedTools.size() + " tools after " + filterReason);
214310
}
215311

216-
public void start() {
217-
initTools();
218-
219-
if (httpServerManager != null) {
220-
LOG.info("Starting HTTP server on " + mcpConfiguration.getHttpHost() + ":" + mcpConfiguration.getHttpPort() + "...");
221-
httpServerManager.startServer().join();
222-
LOG.info("HTTP server started");
223-
}
224-
225-
Function<Object, McpSyncServer> serverBuilder = provider -> {
226-
var builder = switch (provider) {
227-
case McpServerTransportProvider p -> McpServer.sync(p);
228-
case McpStreamableServerTransportProvider p -> McpServer.sync(p);
229-
default -> throw new IllegalArgumentException("Unsupported transport provider type: " + provider.getClass().getName());
230-
};
231-
return builder
232-
.serverInfo(new McpSchema.Implementation(SONARQUBE_MCP_SERVER_NAME, mcpConfiguration.getAppVersion()))
233-
.instructions("Transform your code quality workflow with SonarQube integration. " +
234-
"Analyze code, monitor project health, investigate issues, and understand quality gates.")
235-
.capabilities(McpSchema.ServerCapabilities.builder().tools(true).logging().build())
236-
.tools(supportedTools.stream().map(this::toSpec).toArray(McpServerFeatures.SyncToolSpecification[]::new))
237-
.build();
238-
};
239-
240-
syncServer = serverBuilder.apply(transportProvider);
241-
242-
Runtime.getRuntime().addShutdownHook(new Thread(this::shutdown));
243-
244-
logInitialization();
245-
}
246-
247312
private McpServerFeatures.SyncToolSpecification toSpec(Tool tool) {
248313
return new McpServerFeatures.SyncToolSpecification.Builder()
249314
.tool(tool.definition())
@@ -259,7 +324,7 @@ private void logInitialization() {
259324
var sonarQubeType = mcpConfiguration.isSonarCloud() ? "SonarQube Cloud" : "SonarQube Server";
260325

261326
LOG.info("========================================");
262-
LOG.info("SonarQube MCP Server Startup Configuration:");
327+
LOG.info("SonarQube MCP Server Started:");
263328
LOG.info("Transport: " + transportType +
264329
(mcpConfiguration.isHttpEnabled() ? (" (" + mcpConfiguration.getHttpHost() + ":" + mcpConfiguration.getHttpPort() + ")") : ""));
265330
LOG.info("Instance: " + sonarQubeType);
@@ -270,7 +335,7 @@ private void logInitialization() {
270335
if (mcpConfiguration.isReadOnlyMode()) {
271336
LOG.info("Mode: READ-ONLY (write operations disabled)");
272337
}
273-
LOG.info("Tools loaded: " + supportedTools.size());
338+
LOG.info("Status: Server ready - tools loading in background");
274339
LOG.info("========================================");
275340
}
276341

@@ -365,7 +430,7 @@ public SonarQubeMcpServer(McpServerTransportProviderBase transportProvider, @Nul
365430
this.mcpConfiguration = new McpServerLaunchConfiguration(environment);
366431
this.transportProvider = transportProvider;
367432
this.httpServerManager = httpServerManager;
368-
initializeServices();
433+
initializeBasicServices();
369434
}
370435

371436
// Package-private getters for testing
@@ -377,4 +442,13 @@ public List<Tool> getSupportedTools() {
377442
return List.copyOf(supportedTools);
378443
}
379444

445+
/**
446+
* For testing: wait for background initialization to complete.
447+
* This ensures tools are fully loaded before tests proceed.
448+
*/
449+
@VisibleForTesting
450+
public void waitForInitialization() throws ExecutionException, InterruptedException {
451+
initializationFuture.get();
452+
}
453+
380454
}

src/main/java/org/sonarsource/sonarqube/mcp/tools/ToolExecutor.java

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
package org.sonarsource.sonarqube.mcp.tools;
1818

1919
import io.modelcontextprotocol.spec.McpSchema;
20+
import java.util.concurrent.CompletableFuture;
21+
import java.util.concurrent.ExecutionException;
22+
import java.util.concurrent.TimeUnit;
23+
import java.util.concurrent.TimeoutException;
2024
import org.eclipse.lsp4j.jsonrpc.ResponseErrorException;
2125
import org.sonarsource.sonarqube.mcp.log.McpLogger;
2226
import org.sonarsource.sonarqube.mcp.serverapi.exception.ForbiddenException;
@@ -26,10 +30,13 @@
2630

2731
public class ToolExecutor {
2832
private static final McpLogger LOG = McpLogger.getInstance();
33+
private static final int INITIALIZATION_TIMEOUT_SECONDS = 300; // 5 minutes
2934
private final BackendService backendService;
35+
private final CompletableFuture<Void> initializationFuture;
3036

31-
public ToolExecutor(BackendService backendService) {
37+
public ToolExecutor(BackendService backendService, CompletableFuture<Void> initializationFuture) {
3238
this.backendService = backendService;
39+
this.initializationFuture = initializationFuture;
3340
}
3441

3542
public McpSchema.CallToolResult execute(Tool tool, McpSchema.CallToolRequest toolRequest) {
@@ -39,9 +46,24 @@ public McpSchema.CallToolResult execute(Tool tool, McpSchema.CallToolRequest too
3946
var startTime = System.currentTimeMillis();
4047
Tool.Result result;
4148
try {
49+
// Wait for initialization to complete before executing the tool
50+
if (!initializationFuture.isDone()) {
51+
LOG.info("Waiting for server initialization to complete before executing tool: " + toolName);
52+
}
53+
initializationFuture.get(INITIALIZATION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
54+
4255
result = tool.execute(new Tool.Arguments(toolRequest.arguments()));
4356
var executionTime = System.currentTimeMillis() - startTime;
4457
LOG.info("Tool completed: " + toolName + " (execution time: " + executionTime + "ms)");
58+
} catch (TimeoutException e) {
59+
var executionTime = System.currentTimeMillis() - startTime;
60+
result = Tool.Result.failure("Server initialization is taking longer than expected. Please try again in a moment.");
61+
LOG.error("Tool failed due to initialization timeout: " + toolName + " (execution time: " + executionTime + "ms)", e);
62+
} catch (ExecutionException e) {
63+
var executionTime = System.currentTimeMillis() - startTime;
64+
result = Tool.Result.failure("Server initialization failed: " + e.getCause().getMessage() +
65+
". Please check the server logs for more details.");
66+
LOG.error("Tool failed due to initialization error: " + toolName + " (execution time: " + executionTime + "ms)", e);
4567
} catch (Exception e) {
4668
var executionTime = System.currentTimeMillis() - startTime;
4769
var message = switch (e) {

src/test/java/org/sonarsource/sonarqube/mcp/SonarQubeMcpServerHttpTest.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,14 @@ void should_use_custom_http_configuration(SonarQubeMcpServerTestHarness harness)
7272
}
7373

7474
@SonarQubeMcpServerTest
75-
void should_have_same_tools_regardless_of_transport(SonarQubeMcpServerTestHarness harness) {
75+
void should_have_same_tools_regardless_of_transport(SonarQubeMcpServerTestHarness harness) throws Exception {
7676
var environment = createTestEnvironment(harness.getMockSonarQubeServer().baseUrl());
7777

7878
harness.prepareMockWebServer(environment);
7979

8080
var stdioServer = new SonarQubeMcpServer(environment);
8181
stdioServer.start();
82+
stdioServer.waitForInitialization(); // Wait for tools to load
8283
var stdioTools = stdioServer.getSupportedTools().stream()
8384
.map(tool -> tool.definition().name())
8485
.sorted()
@@ -87,6 +88,7 @@ void should_have_same_tools_regardless_of_transport(SonarQubeMcpServerTestHarnes
8788
environment.put("SONARQUBE_TRANSPORT", "http");
8889
var httpServer = new SonarQubeMcpServer(environment);
8990
httpServer.start();
91+
httpServer.waitForInitialization(); // Wait for tools to load
9092
var httpTools = httpServer.getSupportedTools().stream()
9193
.map(tool -> tool.definition().name())
9294
.sorted()

src/test/java/org/sonarsource/sonarqube/mcp/SonarQubeMcpServerIdeBridgeTest.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ void cleanup() {
5050
}
5151

5252
@SonarQubeMcpServerTest
53-
void should_add_bridge_related_tools_when_ide_bridge_is_available(SonarQubeMcpServerTestHarness harness) {
53+
void should_add_bridge_related_tools_when_ide_bridge_is_available(SonarQubeMcpServerTestHarness harness) throws Exception {
5454
var environment = new HashMap<String, String>();
5555
environment.put("SONARQUBE_URL", harness.getMockSonarQubeServer().baseUrl());
5656
environment.put("SONARQUBE_TOKEN", "test-token");
@@ -61,6 +61,7 @@ void should_add_bridge_related_tools_when_ide_bridge_is_available(SonarQubeMcpSe
6161

6262
var server = new SonarQubeMcpServer(new StdioServerTransportProvider(new ObjectMapper()), null, environment);
6363
server.start();
64+
server.waitForInitialization();
6465

6566
var supportedTools = server.getSupportedTools();
6667
assertThat(supportedTools)
@@ -70,7 +71,7 @@ void should_add_bridge_related_tools_when_ide_bridge_is_available(SonarQubeMcpSe
7071
}
7172

7273
@SonarQubeMcpServerTest
73-
void should_add_analysis_tool_when_ide_bridge_is_not_available(SonarQubeMcpServerTestHarness harness) {
74+
void should_add_analysis_tool_when_ide_bridge_is_not_available(SonarQubeMcpServerTestHarness harness) throws Exception {
7475
var environment = new HashMap<String, String>();
7576
environment.put("SONARQUBE_URL", harness.getMockSonarQubeServer().baseUrl());
7677
environment.put("SONARQUBE_TOKEN", "test-token");
@@ -80,6 +81,7 @@ void should_add_analysis_tool_when_ide_bridge_is_not_available(SonarQubeMcpServe
8081

8182
var server = new SonarQubeMcpServer(new StdioServerTransportProvider(new ObjectMapper()), null, environment);
8283
server.start();
84+
server.waitForInitialization();
8385

8486
var supportedTools = server.getSupportedTools();
8587
assertThat(supportedTools)

src/test/java/org/sonarsource/sonarqube/mcp/harness/SonarQubeMcpServerTestHarness.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,15 @@ public SonarQubeMcpTestClient newClient(Map<String, String> overriddenEnv) {
143143
client.initialize();
144144
this.clients.add(client);
145145
this.servers.add(server);
146+
147+
// Wait for background initialization to complete before returning
148+
// This ensures tools are fully loaded and available for tests
149+
try {
150+
server.waitForInitialization();
151+
} catch (Exception e) {
152+
throw new RuntimeException("Server initialization failed", e);
153+
}
154+
146155
return new SonarQubeMcpTestClient(client);
147156
}
148157

src/test/java/org/sonarsource/sonarqube/mcp/tools/ToolExecutorTest.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import io.modelcontextprotocol.spec.McpSchema;
2121
import java.util.List;
2222
import java.util.Map;
23+
import java.util.concurrent.CompletableFuture;
2324
import org.junit.jupiter.api.BeforeEach;
2425
import org.junit.jupiter.api.Test;
2526
import org.sonarsource.sonarqube.mcp.slcore.BackendService;
@@ -35,7 +36,9 @@ class ToolExecutorTest {
3536
@BeforeEach
3637
void prepare() {
3738
mockBackendService = mock(BackendService.class);
38-
toolExecutor = new ToolExecutor(mockBackendService);
39+
// Create a completed future for tests (simulating completed initialization)
40+
CompletableFuture<Void> initializationFuture = CompletableFuture.completedFuture(null);
41+
toolExecutor = new ToolExecutor(mockBackendService, initializationFuture);
3942
}
4043

4144
@Test

0 commit comments

Comments
 (0)