Skip to content

Commit 3e101ea

Browse files
committed
Clean up
1 parent 952fe04 commit 3e101ea

File tree

9 files changed

+481
-120
lines changed

9 files changed

+481
-120
lines changed

README.md

Lines changed: 14 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -556,8 +556,8 @@ Connecting your SonarQube MCP Server with your SonarQube Cloud **US** instance r
556556

557557
The SonarQube MCP Server supports three transport modes:
558558

559-
#### 1. **Stdio** (Default - Single User)
560-
Default mode for single-user setups via command-line tools or MCP clients.
559+
#### 1. **Stdio** (Default - Recommended for Local Development)
560+
The recommended mode for local development and single-user setups, used by all MCP clients.
561561

562562
**Example - Docker with SonarQube Cloud:**
563563
```json
@@ -575,49 +575,29 @@ Default mode for single-user setups via command-line tools or MCP clients.
575575
}
576576
```
577577

578-
#### 2. **HTTP** (Multi-User - Recommended for Local Development)
579-
Enables multiple clients to connect to a shared server. Each client provides their own token.
578+
#### 2. **HTTP**
579+
Unencrypted HTTP transport. Use HTTPS instead for multi-user deployments.
580+
581+
> ⚠️ **Not Recommended:** Use **Stdio** for local development or **HTTPS** for multi-user production deployments.
580582
581583
| Environment variable | Description | Default |
582584
|----------------------|------------------------------------------------------------------|-----------------|
583585
| `SONARQUBE_TRANSPORT`| Set to `http` to enable HTTP transport | Not set (stdio) |
584586
| `SONARQUBE_HTTP_PORT`| Port number (1024-65535) | `8080` |
585-
| `SONARQUBE_HTTP_HOST`| Host to bind (`127.0.0.1` for localhost, `0.0.0.0` for Docker) | `127.0.0.1` |
586-
587-
**Example - Docker with SonarQube Cloud:**
588-
```bash
589-
docker run --name sonarqube-mcp-server -p 8080:8080 \
590-
-e SONARQUBE_TRANSPORT=http \
591-
-e SONARQUBE_HTTP_HOST=0.0.0.0 \
592-
-e SONARQUBE_TOKEN="<init-token>" \
593-
-e SONARQUBE_ORG="<your-org>" \
594-
mcp/sonarqube
595-
```
596-
597-
**Client Configuration:**
598-
```json
599-
{
600-
"mcpServers": {
601-
"sonarqube-http": {
602-
"url": "http://127.0.0.1:8080/mcp",
603-
"headers": {
604-
"SONARQUBE_TOKEN": "<your-token>"
605-
}
606-
}
607-
}
608-
}
609-
```
587+
| `SONARQUBE_HTTP_HOST`| Host to bind (defaults to localhost for security) | `127.0.0.1` |
610588

611589
**Note:** In HTTP(S) mode, each client sends their own token via the `SONARQUBE_TOKEN` header. The server's `SONARQUBE_TOKEN` is only used for initialization.
612590

613-
#### 3. **HTTPS** (Multi-User - Production)
614-
Same as HTTP but with TLS encryption. Requires SSL certificates.
591+
#### 3. **HTTPS** (Recommended for Multi-User Production Deployments)
592+
Secure multi-user transport with TLS encryption. Requires SSL certificates.
593+
594+
> **Recommended for Production:** Use HTTPS when deploying the MCP server for multiple users. The server binds to `127.0.0.1` (localhost) by default for security.
615595
616596
| Environment variable | Description | Default |
617597
|----------------------|------------------------------------------------------------------|-----------------|
618598
| `SONARQUBE_TRANSPORT`| Set to `https` to enable HTTPS transport | Not set (stdio) |
619599
| `SONARQUBE_HTTP_PORT`| Port number (typically 8443 for HTTPS) | `8080` |
620-
| `SONARQUBE_HTTP_HOST`| Host to bind (`127.0.0.1` for localhost, `0.0.0.0` for Docker) | `127.0.0.1` |
600+
| `SONARQUBE_HTTP_HOST`| Host to bind (defaults to localhost for security) | `127.0.0.1` |
621601

622602
**SSL Certificate Configuration (Optional):**
623603

@@ -632,7 +612,6 @@ Same as HTTP but with TLS encryption. Requires SSL certificates.
632612
docker run --name sonarqube-mcp-server -p 8443:8443 \
633613
-v $(pwd)/keystore.p12:/etc/ssl/mcp/keystore.p12:ro \
634614
-e SONARQUBE_TRANSPORT=https \
635-
-e SONARQUBE_HTTP_HOST=0.0.0.0 \
636615
-e SONARQUBE_HTTP_PORT=8443 \
637616
-e SONARQUBE_TOKEN="<init-token>" \
638617
-e SONARQUBE_ORG="<your-org>" \
@@ -644,7 +623,7 @@ docker run --name sonarqube-mcp-server -p 8443:8443 \
644623
{
645624
"mcpServers": {
646625
"sonarqube-https": {
647-
"url": "https://127.0.0.1:8443/mcp",
626+
"url": "https://your-server:8443/mcp",
648627
"headers": {
649628
"SONARQUBE_TOKEN": "<your-token>"
650629
}
@@ -653,7 +632,7 @@ docker run --name sonarqube-mcp-server -p 8443:8443 \
653632
}
654633
```
655634

656-
**Note:** For local development, use HTTP instead of HTTPS to avoid certificate issues. For production deployments with proper SSL certificates from a trusted CA, use HTTPS.
635+
**Note:** For local development, use Stdio transport instead (the default). HTTPS is intended for multi-user production deployments with proper SSL certificates.
657636

658637
### Custom Certificates
659638

docs/http-authentication-architecture.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@
1414

1515
## Overview
1616

17-
The SonarQube MCP Server supports two transport modes:
17+
The SonarQube MCP Server supports three transport modes:
1818

19-
- **Stdio Transport**: Direct process communication (stdin/stdout)
20-
- **HTTP Transport**: Network-based communication using MCP Streamable HTTP specification
19+
- **Stdio Transport** (Recommended for local development): Direct process communication (stdin/stdout)
20+
- **HTTP Transport** (Not recommended): Unencrypted network communication
21+
- **HTTPS Transport** (Recommended for production): Secure network-based communication with TLS encryption
2122

22-
This document focuses on the **HTTP Transport** and its authentication mechanism.
23+
This document focuses on the **HTTP/HTTPS Transport** and its authentication mechanism.
2324

2425
---
2526

@@ -209,8 +210,9 @@ The flow is:
209210
**Mitigation**: `McpSecurityFilter` validates `Origin` header:
210211

211212
**Allowed Origins**:
212-
- When bound to `127.0.0.1`: Only `http://localhost`, `http://127.0.0.1`, etc.
213-
- When bound to `0.0.0.0`: All origins allowed (less secure, logs warning)
213+
- When bound to `127.0.0.1` (default): Only `http://localhost`, `http://127.0.0.1`, etc.
214+
215+
> ⚠️ **Important**: The server defaults to binding to `127.0.0.1` (localhost) for security. This is the recommended configuration. Binding to other interfaces is not supported for production use.
214216
215217
### Token Security
216218

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

Lines changed: 51 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -429,51 +429,69 @@ public void shutdown() {
429429
}
430430
isShutdown = true;
431431

432-
// Wait for background initialization to complete or cancel it
433-
if (!initializationFuture.isDone()) {
434-
LOG.info("Waiting for background initialization to complete before shutdown...");
435-
try {
436-
initializationFuture.get(30, java.util.concurrent.TimeUnit.SECONDS);
437-
} catch (TimeoutException | ExecutionException e) {
438-
LOG.warn("Background initialization did not complete within 30 seconds, proceeding with shutdown");
439-
initializationFuture.cancel(true);
440-
} catch (Exception e) {
441-
LOG.error("Background initialization failed or was interrupted", e);
442-
}
432+
awaitBackgroundInitialization();
433+
shutdownHttpServer();
434+
shutdownHttpClient();
435+
shutdownMcpServer();
436+
shutdownBackend();
437+
}
438+
439+
private void awaitBackgroundInitialization() {
440+
if (initializationFuture.isDone()) {
441+
return;
443442
}
443+
LOG.info("Waiting for background initialization to complete before shutdown...");
444+
try {
445+
initializationFuture.get(30, java.util.concurrent.TimeUnit.SECONDS);
446+
} catch (TimeoutException | ExecutionException e) {
447+
LOG.warn("Background initialization did not complete within 30 seconds, proceeding with shutdown");
448+
initializationFuture.cancel(true);
449+
} catch (Exception e) {
450+
LOG.error("Background initialization failed or was interrupted", e);
451+
}
452+
}
444453

445-
// Stop HTTP server if running
446-
if (httpServerManager != null) {
447-
try {
448-
LOG.info("Stopping HTTP server...");
449-
httpServerManager.stopServer().join();
450-
LOG.info("HTTP server stopped");
451-
} catch (Exception e) {
452-
LOG.error("Error shutting down HTTP server", e);
453-
}
454-
455-
// Clean up session token store (only used in HTTP mode)
456-
try {
457-
SessionTokenStore.getInstance().shutdown();
458-
} catch (Exception e) {
459-
LOG.error("Error shutting down session token store", e);
460-
}
454+
private void shutdownHttpServer() {
455+
if (httpServerManager == null) {
456+
return;
457+
}
458+
try {
459+
LOG.info("Stopping HTTP server...");
460+
httpServerManager.stopServer().join();
461+
LOG.info("HTTP server stopped");
462+
} catch (Exception e) {
463+
LOG.error("Error shutting down HTTP server", e);
461464
}
465+
try {
466+
SessionTokenStore.getInstance().shutdown();
467+
} catch (Exception e) {
468+
LOG.error("Error shutting down session token store", e);
469+
}
470+
}
462471

472+
private void shutdownHttpClient() {
473+
if (httpClientProvider == null) {
474+
return;
475+
}
463476
try {
464-
if (httpClientProvider != null) {
465-
httpClientProvider.shutdown();
466-
}
477+
httpClientProvider.shutdown();
467478
} catch (Exception e) {
468479
LOG.error("Error shutting down HTTP client", e);
469480
}
481+
}
482+
483+
private void shutdownMcpServer() {
484+
if (syncServer == null) {
485+
return;
486+
}
470487
try {
471-
if (syncServer != null) {
472-
syncServer.closeGracefully();
473-
}
488+
syncServer.closeGracefully();
474489
} catch (Exception e) {
475490
LOG.error("Error shutting down MCP server", e);
476491
}
492+
}
493+
494+
private void shutdownBackend() {
477495
try {
478496
backendService.shutdown();
479497
} catch (Exception e) {

src/main/java/org/sonarsource/sonarqube/mcp/authentication/AuthenticationFilter.java

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,6 @@ private static void sendUnauthorizedResponse(HttpServletResponse response, Strin
136136
response.getWriter().write(errorJson);
137137
}
138138

139-
/**
140-
* Send 403 Forbidden response for session hijacking attempts.
141-
* Per MCP Security Best Practices: reject requests where token doesn't match session binding.
142-
*/
143139
private static void sendForbiddenResponse(HttpServletResponse response) throws IOException {
144140
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
145141
response.setContentType("application/json");

src/main/java/org/sonarsource/sonarqube/mcp/authentication/SessionTokenStore.java

Lines changed: 17 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,11 @@
2222
import java.util.concurrent.Executors;
2323
import java.util.concurrent.ScheduledExecutorService;
2424
import java.util.concurrent.TimeUnit;
25+
import java.util.concurrent.atomic.AtomicBoolean;
2526
import javax.annotation.Nullable;
26-
import org.sonarsource.sonarqube.mcp.log.McpLogger;
2727

2828
/**
2929
* Thread-safe store for session-to-token mappings with TTL-based expiration.
30-
* <p>
31-
* This store is the key to preventing session hijacking. Instead of relying on
32-
* InheritableThreadLocal (which can leak tokens across requests when threads are
33-
* reused from pools), we store tokens by session ID and look them up at execution time.
34-
* <p>
3530
* Sessions are automatically expired after a period of inactivity to prevent
3631
* memory leaks and ensure stale sessions are cleaned up.
3732
* <p>
@@ -48,20 +43,21 @@ public class SessionTokenStore {
4843
private static final Duration SESSION_TTL = Duration.ofHours(1);
4944
private static final Duration CLEANUP_INTERVAL = Duration.ofMinutes(5);
5045
private static final SessionTokenStore INSTANCE = new SessionTokenStore();
51-
52-
/** Session entry with token and last access timestamp */
46+
47+
/**
48+
* Session entry with token and last access timestamp
49+
*/
5350
private record SessionEntry(String token, Instant lastAccess) {
5451
SessionEntry touch() {
5552
return new SessionEntry(token, Instant.now());
5653
}
5754
}
58-
55+
5956
// Maps sessionId → SessionEntry (token + last access time)
6057
private final ConcurrentHashMap<String, SessionEntry> sessionTokens = new ConcurrentHashMap<>();
6158
private final ScheduledExecutorService cleanupScheduler;
6259

6360
private SessionTokenStore() {
64-
// Start background cleanup task
6561
cleanupScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
6662
var thread = new Thread(r, "session-cleanup");
6763
thread.setDaemon(true);
@@ -82,16 +78,10 @@ public static SessionTokenStore getInstance() {
8278
/**
8379
* Store the token for a NEW session only.
8480
* For existing sessions, validates that the token matches and refreshes the TTL.
85-
*
86-
* @param sessionId The MCP session ID
87-
* @param token The token from the request
88-
* @return true if token was stored/matches, false if token mismatch (hijacking attempt)
8981
*/
9082
public boolean setTokenIfValid(String sessionId, String token) {
9183
var newEntry = new SessionEntry(token, Instant.now());
92-
93-
// Use compute to atomically check-and-set or validate
94-
var result = new boolean[]{true};
84+
var result = new AtomicBoolean(true);
9585
sessionTokens.compute(sessionId, (key, existing) -> {
9686
if (existing == null) {
9787
// New session - store the entry
@@ -103,31 +93,28 @@ public boolean setTokenIfValid(String sessionId, String token) {
10393
return existing.touch();
10494
}
10595
// Token mismatch
106-
result[0] = false;
107-
return existing; // Keep existing entry unchanged
96+
result.set(false);
97+
return existing;
10898
});
109-
110-
return result[0];
99+
100+
return result.get();
111101
}
112102

113103
/**
114104
* Get the token for a session, refreshing its TTL.
115105
* Called by tool execution to get the correct token.
116-
*
117-
* @param sessionId The MCP session ID
118-
* @return The token, or null if session not found or expired
119106
*/
120107
@Nullable
121108
public String getToken(String sessionId) {
122109
var entry = sessionTokens.computeIfPresent(sessionId, (key, existing) -> {
123110
// Check if expired
124111
if (isExpired(existing)) {
125-
return null; // Remove expired entry
112+
return null;
126113
}
127114
// Refresh TTL on access
128115
return existing.touch();
129116
});
130-
117+
131118
return entry != null ? entry.token() : null;
132119
}
133120

@@ -157,21 +144,13 @@ public void shutdown() {
157144
}
158145
clear();
159146
}
160-
161-
private boolean isExpired(SessionEntry entry) {
147+
148+
private static boolean isExpired(SessionEntry entry) {
162149
return Duration.between(entry.lastAccess(), Instant.now()).compareTo(SESSION_TTL) > 0;
163150
}
164-
151+
165152
private void cleanupExpiredSessions() {
166-
var expiredCount = 0;
167-
var iterator = sessionTokens.entrySet().iterator();
168-
while (iterator.hasNext()) {
169-
var entry = iterator.next();
170-
if (isExpired(entry.getValue())) {
171-
iterator.remove();
172-
expiredCount++;
173-
}
174-
}
153+
sessionTokens.entrySet().removeIf(entry -> isExpired(entry.getValue()));
175154
}
176155

177156
}

src/main/java/org/sonarsource/sonarqube/mcp/context/RequestContext.java

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,14 @@ public static RequestContext current() {
3939
/**
4040
* Set the request context for the current thread.
4141
* This should be called by the tool execution handler with the session ID from the MCP exchange.
42-
*
43-
* @param sessionId the MCP session ID
4442
*/
4543
public static void set(String sessionId) {
4644
CONTEXT.set(new RequestContext(sessionId));
4745
}
4846

4947
/**
5048
* Clear the request context for the current thread.
51-
* This MUST be called after request processing completes to avoid memory leaks
52-
* and thread pool contamination.
49+
* This MUST be called after request processing completes to avoid memory leak and thread pool contamination.
5350
*/
5451
public static void clear() {
5552
CONTEXT.remove();

0 commit comments

Comments
 (0)