Skip to content

Commit b39ee6a

Browse files
authored
MCP-313 Support SONARQUBE_TOOLSETS and SONARQUBE_READ_ONLY headers (#235)
1 parent f5537c9 commit b39ee6a

9 files changed

Lines changed: 592 additions & 42 deletions

File tree

README.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -535,8 +535,8 @@ By default, only important toolsets are enabled to reduce context overhead. You
535535

536536
| Environment variable | Description |
537537
|------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
538-
| `SONARQUBE_TOOLSETS` | Comma-separated list of toolsets to enable. When set, only these toolsets will be available. If not set, default important toolsets are enabled (`analysis`, `issues`, `projects`, `quality-gates`, `rules`, `duplications`, `measures`, `security-hotspots`, `dependency-risks`). **Note:** The `projects` toolset is always enabled as it's required to find project keys for other operations. |
539-
| `SONARQUBE_READ_ONLY` | When set to `true`, enables read-only mode which disables all write operations (changing issue status for example). This filter is cumulative with `SONARQUBE_TOOLSETS` if both are set. Default: `false`. |
538+
| `SONARQUBE_TOOLSETS` | Comma-separated list of toolsets to enable. When set, only these toolsets will be available. If not set, default important toolsets are enabled (`analysis`, `issues`, `projects`, `quality-gates`, `rules`, `duplications`, `measures`, `security-hotspots`, `dependency-risks`). **Note:** The `projects` toolset is always enabled as it's required to find project keys for other operations. In HTTP(S) mode, clients can send a `SONARQUBE_TOOLSETS` HTTP header to narrow this further per-request, but cannot enable toolsets beyond what the server was launched with (see [HTTP/HTTPS Transport](#2-http) below). |
539+
| `SONARQUBE_READ_ONLY` | When set to `true`, enables read-only mode which disables all write operations (changing issue status for example). This filter is cumulative with `SONARQUBE_TOOLSETS` if both are set. Default: `false`. In HTTP(S) mode, clients can send a `SONARQUBE_READ_ONLY` HTTP header to further restrict individual requests to read-only, but cannot lift a server-level read-only restriction (see [HTTP/HTTPS Transport](#2-http) below). |
540540

541541
<details>
542542
<summary>Available Toolsets</summary>
@@ -648,7 +648,7 @@ Unencrypted HTTP transport. Use HTTPS instead for multi-user deployments.
648648
**Note:** In HTTP(S) mode, the server is stateless — each client request must include a `SONARQUBE_TOKEN` header carrying the user's own SonarQube token. For SonarQube Cloud, the organization is resolved as follows:
649649
- If `SONARQUBE_ORG` is set at server startup, all requests are routed to that organization. Clients must **not** send a `SONARQUBE_ORG` header — doing so will result in an error.
650650
- If `SONARQUBE_ORG` is not set at server startup, each client **must** supply a `SONARQUBE_ORG` header on every request.
651-
651+
Clients can also narrow the visible tools per-request by supplying `SONARQUBE_TOOLSETS` and/or `SONARQUBE_READ_ONLY` headers; these apply additional filtering on top of the server-level configuration — they can only reduce the scope, never expand it.
652652
No session state is maintained between requests.
653653

654654
#### 3. **HTTPS** (Recommended for Multi-User Production Deployments)
@@ -689,7 +689,9 @@ docker run --init --pull=always -p 8443:8443 \
689689
"url": "https://your-server:8443/mcp",
690690
"headers": {
691691
"SONARQUBE_TOKEN": "<your-token>",
692-
"SONARQUBE_ORG": "<your-org>"
692+
"SONARQUBE_ORG": "<your-org>",
693+
"SONARQUBE_TOOLSETS": "issues,quality-gates",
694+
"SONARQUBE_READ_ONLY": "true"
693695
}
694696
}
695697
}
@@ -703,13 +705,17 @@ docker run --init --pull=always -p 8443:8443 \
703705
"sonarqube-https": {
704706
"url": "https://your-server:8443/mcp",
705707
"headers": {
706-
"SONARQUBE_TOKEN": "<your-token>"
708+
"SONARQUBE_TOKEN": "<your-token>",
709+
"SONARQUBE_TOOLSETS": "issues,quality-gates",
710+
"SONARQUBE_READ_ONLY": "true"
707711
}
708712
}
709713
}
710714
}
711715
```
712716

717+
> **Note:** `SONARQUBE_TOOLSETS` and `SONARQUBE_READ_ONLY` are optional per-request headers that narrow the server-level tool set for that specific request. They can only reduce scope — they cannot enable toolsets or lift restrictions beyond what the server was launched with.
718+
713719
**Note:** For local development, use Stdio transport instead (the default). HTTPS is intended for multi-user production deployments with proper SSL certificates.
714720

715721
### Custom Certificates

docs/http-authentication-architecture.md

Lines changed: 99 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
2. [Architecture Components](#architecture-components)
99
3. [Authentication Flow](#authentication-flow)
1010
4. [Token Propagation](#token-propagation)
11-
5. [Security Considerations](#security-considerations)
11+
5. [Per-Request Tool Filtering](#per-request-tool-filtering)
12+
6. [Security Considerations](#security-considerations)
1213

1314
---
1415

@@ -34,7 +35,10 @@ This document focuses on the **HTTP/HTTPS Transport** and its authentication mec
3435
│ (Cursor, VS Code, etc.) │
3536
└────────────────┬────────────────────────────────────┘
3637
│ HTTP POST
37-
│ Headers: SONARQUBE_TOKEN (required), SONARQUBE_ORG (optional, SQC only)
38+
│ Headers: SONARQUBE_TOKEN (required)
39+
│ SONARQUBE_ORG (optional, SQC only)
40+
│ SONARQUBE_TOOLSETS (optional, per-request override)
41+
│ SONARQUBE_READ_ONLY (optional, per-request override)
3842
3943
┌─────────────────────────────────────────────────────┐
4044
│ Jetty HTTP Server │
@@ -44,40 +48,51 @@ This document focuses on the **HTTP/HTTPS Transport** and its authentication mec
4448
4549
┌─────────────────────────────────────────────────────┐
4650
│ Servlet Filter Chain │
47-
│ 1. AuthenticationFilter (token validation)
48-
│ 2. McpSecurityFilter (CORS + Origin validation) │
51+
│ 1. McpSecurityFilter (CORS + Origin validation)
52+
│ 2. AuthenticationFilter (token validation)
4953
└────────────────┬────────────────────────────────────┘
5054
5155
5256
┌─────────────────────────────────────────────────────┐
5357
│ HttpServletStatelessServerTransport │
54-
│ (MCP SDK - stateless, extracts McpTransportCtx) │
58+
│ (MCP SDK - stateless, extracts McpTransportCtx) │
59+
└────────────────┬────────────────────────────────────┘
60+
61+
62+
┌─────────────────────────────────────────────────────┐
63+
│ PerRequestToolFilteringHandler │
64+
│ (per-request tools/list filtering) │
5565
└────────────────┬────────────────────────────────────┘
5666
5767
5868
┌─────────────────────────────────────────────────────┐
5969
│ MCP Tool Execution │
60-
│ (reads token from McpTransportContext ThreadLocal) │
70+
│ (reads token + toolset filters from │
71+
│ McpTransportContext ThreadLocal) │
6172
└─────────────────────────────────────────────────────┘
6273
```
6374

6475
### 2. Key Classes
6576

6677
#### `HttpServerTransportProvider`
6778
- **Location**: `org.sonarsource.sonarqube.mcp.transport.HttpServerTransportProvider`
68-
- **Purpose**: Bootstraps Jetty server and configures the stateless servlet transport with a context extractor that reads the `SONARQUBE_TOKEN` header into a `McpTransportContext` for each request
79+
- **Purpose**: Bootstraps Jetty server and configures the stateless servlet transport with a context extractor that reads `SONARQUBE_TOKEN`, `SONARQUBE_ORG`, `SONARQUBE_TOOLSETS`, and `SONARQUBE_READ_ONLY` headers into a `McpTransportContext` for each request
6980

7081
#### `AuthenticationFilter`
7182
- **Location**: `org.sonarsource.sonarqube.mcp.authentication.AuthenticationFilter`
72-
- **Purpose**: Validates that every request carries a non-blank `SONARQUBE_TOKEN` header. No session state is created or maintained.
83+
- **Purpose**: Validates that every request carries a non-blank `SONARQUBE_TOKEN` header. No session state is created or maintained. Runs **after** `McpSecurityFilter` so CORS preflight (OPTIONS) requests and Origin validation are handled before authentication, allowing browsers to complete their preflight handshake without a token.
7384

7485
#### `McpSecurityFilter`
7586
- **Location**: `org.sonarsource.sonarqube.mcp.transport.McpSecurityFilter`
76-
- **Purpose**: Security and CORS handling
87+
- **Purpose**: Security and CORS handling (Origin validation, CORS headers)
88+
89+
#### `PerRequestToolFilteringHandler`
90+
- **Location**: `org.sonarsource.sonarqube.mcp.transport.PerRequestToolFilteringHandler`
91+
- **Purpose**: Wraps the SDK's `McpStatelessServerHandler` to intercept `tools/list` responses and filter the returned tool list based on the per-request `SONARQUBE_TOOLSETS` and `SONARQUBE_READ_ONLY` headers from `McpTransportContext`. A well-behaved MCP client will only call tools it received from `tools/list`, so filtering the list is sufficient enforcement.
7792

7893
#### `SonarQubeMcpServer` (ServerApiProvider)
7994
- **Location**: `org.sonarsource.sonarqube.mcp.SonarQubeMcpServer`
80-
- **Purpose**: In HTTP stateless mode, reads the current request's `McpTransportContext` from a `ThreadLocal` to extract the token and create a per-request `ServerApi` instance
95+
- **Purpose**: In HTTP stateless mode, reads the current request's `McpTransportContext` from a `ThreadLocal` to extract the token and create a per-request `ServerApi` instance.
8196

8297
---
8398

@@ -103,26 +118,38 @@ Clients configure the HTTP endpoint with authentication:
103118
### 2. Request Flow
104119

105120
```
106-
1. Client sends HTTP POST with token header
107-
└─> SONARQUBE_TOKEN: squ_abc123
121+
1. Client sends HTTP POST with headers
122+
└─> SONARQUBE_TOKEN: squ_abc123 (required)
123+
└─> SONARQUBE_ORG: my-org (optional, SQC only)
124+
└─> SONARQUBE_TOOLSETS: issues,rules (optional, per-request override)
125+
└─> SONARQUBE_READ_ONLY: true (optional, per-request override)
126+
127+
2. McpSecurityFilter validates security
128+
├─> Allow OPTIONS preflight without authentication (enables CORS handshake)
129+
├─> Check Origin header
130+
├─> Set CORS headers
131+
└─> Pass to next filter
108132
109-
2. AuthenticationFilter intercepts request
133+
3. AuthenticationFilter intercepts request
110134
├─> Extract token from SONARQUBE_TOKEN header
111135
├─> Reject with 401 if token is missing or blank
112-
└─> Pass to next filter if token is present
113-
114-
3. McpSecurityFilter validates security
115-
├─> Check Origin header
116-
├─> Set CORS headers
117-
└─> Pass to servlet
136+
├─> Validate SONARQUBE_ORG header (SonarQube Cloud only)
137+
├─> Validate SONARQUBE_READ_ONLY header (must be 'true' or 'false' if present)
138+
└─> Pass to servlet if all checks pass
118139
119140
4. HttpServletStatelessServerTransport processes request
120-
├─> contextExtractor runs: reads SONARQUBE_TOKEN header
121-
├─> Creates McpTransportContext with the token value
141+
├─> contextExtractor runs: reads SONARQUBE_TOKEN, SONARQUBE_ORG,
142+
│ SONARQUBE_TOOLSETS, and SONARQUBE_READ_ONLY headers
143+
├─> Creates McpTransportContext with all extracted values
122144
├─> Parse JSON-RPC message
123-
└─> Dispatch to tool handler (context available via ThreadLocal)
145+
└─> Dispatch to PerRequestToolFilteringHandler
124146
125-
5. Tool execution (ServerApiProvider.get())
147+
5. PerRequestToolFilteringHandler (tools/list only)
148+
├─> Read SONARQUBE_TOOLSETS and SONARQUBE_READ_ONLY from McpTransportContext
149+
├─> If per-request headers are present, filter tool list accordingly
150+
└─> Return filtered tools/list response (bypasses SDK's unfiltered response)
151+
152+
6. Tool execution - tools/call (ServerApiProvider.get())
126153
├─> Read McpTransportContext from ThreadLocal
127154
├─> Extract CONTEXT_TOKEN_KEY value
128155
├─> Resolve org: use server-level env var (header must be absent) OR per-request header (required if env var not set)
@@ -138,6 +165,8 @@ Clients configure the HTTP endpoint with authentication:
138165
- Uses custom header format:
139166
- `SONARQUBE_TOKEN: <token>` — required on every request
140167
- `SONARQUBE_ORG: <org>` — for SonarQube Cloud, identifies the organization. **Mutually exclusive with the server-level `SONARQUBE_ORG` env var**: if the env var is set at startup, clients must not send this header (results in an error); if the env var is not set, clients must send this header on every request
168+
- `SONARQUBE_TOOLSETS: <comma-separated-keys>` — optional; narrows the server-level toolset for this request (cannot add toolsets beyond what the server was launched with)
169+
- `SONARQUBE_READ_ONLY: true|false` — optional; can further restrict to read-only for this request (cannot lift a server-level read-only restriction)
141170

142171
#### `OAUTH` Mode (Not Yet Implemented)
143172
- OAuth 2.1 with PKCE
@@ -156,13 +185,15 @@ The MCP SDK makes this context available via a `ThreadLocal<McpTransportContext>
156185

157186
```
158187
1. HTTP Request arrives
159-
└─> contextExtractor reads SONARQUBE_TOKEN and SONARQUBE_ORG headers
188+
└─> contextExtractor reads SONARQUBE_TOKEN, SONARQUBE_ORG,
189+
SONARQUBE_TOOLSETS, and SONARQUBE_READ_ONLY headers
160190
└─> McpTransportContext stored in ThreadLocal for this request thread
161191
162-
2. Tool Execution Handler
163-
└─> ThreadLocal<McpTransportContext> is already populated by the SDK
192+
2. PerRequestToolFilteringHandler (tools/list only)
193+
└─> Reads SONARQUBE_TOOLSETS and SONARQUBE_READ_ONLY from McpTransportContext
194+
└─> Filters the SDK's full tool list down to the per-request allowed subset
164195
165-
3. ServerApiProvider.get()
196+
3. ServerApiProvider.get() (tools/call)
166197
└─> Reads McpTransportContext from ThreadLocal
167198
└─> Extracts token and org (strict: server-level env var XOR per-request header — mixing both is an error)
168199
└─> Creates a fresh ServerApi for this request
@@ -177,6 +208,47 @@ The MCP SDK makes this context available via a `ThreadLocal<McpTransportContext>
177208

178209
---
179210

211+
## Per-Request Tool Filtering
212+
213+
In HTTP(S) mode, clients can **narrow** the set of visible tools on a per-request basis by sending optional HTTP headers. These headers apply additional filtering on top of the server-level `SONARQUBE_TOOLSETS` and `SONARQUBE_READ_ONLY` environment variables — they can only reduce the scope, never expand it:
214+
215+
- If the server was launched with `SONARQUBE_READ_ONLY=true`, write tools are absent from the server's tool set and cannot be re-enabled per-request.
216+
- If the server was launched with a restricted `SONARQUBE_TOOLSETS`, per-request headers can select a subset of those toolsets, but cannot add toolsets beyond what the server was launched with.
217+
218+
### Headers
219+
220+
| Header | Description |
221+
|--------|-------------|
222+
| `SONARQUBE_TOOLSETS` | Comma-separated list of toolset keys to enable for this request (e.g., `issues,quality-gates`). Must be a subset of the server-level `SONARQUBE_TOOLSETS`; toolsets not enabled at server startup are silently ignored. The `projects` toolset is always included regardless of this header. |
223+
| `SONARQUBE_READ_ONLY` | Set to `true` to restrict this request to read-only tools only. Has no effect if the server was already launched with `SONARQUBE_READ_ONLY=true` (write tools are already absent). |
224+
225+
### Filtering
226+
227+
Per-request filtering is applied at the **`tools/list` response**: `PerRequestToolFilteringHandler` intercepts the SDK's response and removes tools that are not allowed for this request. The MCP client only sees tools it is permitted to use, reducing its context window accordingly. A well-behaved MCP client will only call tools it received from `tools/list`, so filtering the list is sufficient.
228+
229+
### Security Notes
230+
231+
- The `SONARQUBE_TOOLSETS` and `SONARQUBE_READ_ONLY` headers are **not** authentication headers. They are read by the SDK's `contextExtractor` inside the servlet — which only runs after both `McpSecurityFilter` and `AuthenticationFilter` have passed the request through. An unauthenticated request is rejected with HTTP 401 by `AuthenticationFilter` before the `contextExtractor` (and thus any filtering logic) runs.
232+
- The raw header values are **never** echoed back in responses or error messages. Invalid toolset keys are silently discarded by `ToolCategory.parseCategories()`, preventing any header injection via error payloads.
233+
234+
### Example Client Configuration
235+
236+
```json
237+
{
238+
"mcpServers": {
239+
"sonarqube-https": {
240+
"url": "https://your-server:8443/mcp",
241+
"headers": {
242+
"SONARQUBE_TOKEN": "<your-token>",
243+
"SONARQUBE_ORG": "<your-org>",
244+
"SONARQUBE_TOOLSETS": "issues,quality-gates",
245+
"SONARQUBE_READ_ONLY": "true"
246+
}
247+
}
248+
}
249+
}
250+
```
251+
180252
## Security Considerations
181253

182254
### DNS Rebinding Protection

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,11 +165,12 @@ public SonarQubeMcpServer(Map<String, String> environment) {
165165
public void start() {
166166
if (httpServerManager != null) {
167167
httpServerManager.startServer().join();
168-
statelessSyncServer = McpServer.sync(httpServerManager.getTransportProvider())
168+
var enabledTools = filterForEnabledTools(supportedTools);
169+
statelessSyncServer = McpServer.sync(httpServerManager.getFilteringTransport(enabledTools))
169170
.serverInfo(new McpSchema.Implementation(SONARQUBE_MCP_SERVER_NAME, mcpConfiguration.getAppVersion()))
170171
.instructions(composedInstructions)
171172
.capabilities(McpSchema.ServerCapabilities.builder().tools(true).build())
172-
.tools(filterForEnabledTools(supportedTools).stream().map(this::toStatelessSpec).toArray(McpStatelessServerFeatures.SyncToolSpecification[]::new))
173+
.tools(enabledTools.stream().map(this::toStatelessSpec).toArray(McpStatelessServerFeatures.SyncToolSpecification[]::new))
173174
.build();
174175
} else {
175176
stdioSyncServer = McpServer.sync(transportProvider)

0 commit comments

Comments
 (0)