Skip to content
Draft
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
10 changes: 5 additions & 5 deletions docs/http-authentication-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ This document focuses on the **HTTP/HTTPS Transport**. Authentication is automat
▼
┌─────────────────────────────────────────────────────┐
│ PerRequestToolFilteringHandler │
│ (tools/list and tools/call authorization) │
│ (list schema adjustment and call authorization) │
└────────────────┬────────────────────────────────────┘
│
▼
Expand All @@ -91,7 +91,7 @@ This document focuses on the **HTTP/HTTPS Transport**. Authentication is automat

#### `PerRequestToolFilteringHandler`
- **Location**: `org.sonarsource.sonarqube.mcp.transport.PerRequestToolFilteringHandler`
- **Purpose**: Enforces request tool preferences and verified OAuth scopes on both `tools/list` and `tools/call`. Insufficient-scope calls mark a request-local `OAuthScopeContext`; `OAuthScopeResponseWrapper` converts the SDK response into an HTTP 403 challenge without dispatching the tool.
- **Purpose**: Supplies the SDK discovery filter for request tool preferences and verified OAuth scopes. Adjusts organization schemas in list responses and enforces direct-call authorization. Insufficient-scope calls mark a request-local `OAuthScopeContext`; `OAuthScopeResponseWrapper` converts the SDK response into an HTTP 403 challenge without dispatching the tool.

#### `SonarQubeMcpServer` (ServerApiProvider)
- **Location**: `org.sonarsource.sonarqube.mcp.SonarQubeMcpServer`
Expand Down Expand Up @@ -154,7 +154,7 @@ For SonarQube tokens, clients configure the HTTP endpoint using the preferred `A

5. PerRequestToolFilteringHandler
├─> Read SONARQUBE_TOOLSETS and SONARQUBE_READ_ONLY from McpTransportContext
├─> Filter tools/list and reject disallowed tools/call requests
├─> Delegate tools/list to SDK filtering and reject disallowed tools/call requests
└─> Preserve organization-aware schemas when no organization header is set

6. Tool execution - tools/call (ServerApiProvider.get())
Expand Down Expand Up @@ -246,7 +246,7 @@ The SDK passes the context to the tool handler. `SonarQubeMcpServer.toStatelessS

2. PerRequestToolFilteringHandler
└─> Reads request preferences and, for OAuth requests, verified scopes
└─> Filters tools/list and authorizes tools/call before dispatch
└─> Adjusts organization schemas after SDK discovery and authorizes tools/call before dispatch

3. SonarQubeMcpServer tool wrapper (tools/call)
└─> Sets its ThreadLocal for tool execution and cleans it up in finally
Expand Down Expand Up @@ -281,7 +281,7 @@ In HTTP(S) mode, clients can **narrow** the set of visible tools on a per-reques

### Filtering

`PerRequestToolFilteringHandler` applies the same restrictions to **`tools/list` and direct `tools/call` requests**. Checks run in this order: tool existence and ordinary category/read-only restrictions, OAuth scopes, per-tool enablement, then dispatch. Unknown or ordinarily filtered tools retain their existing errors.
The HTTP server registers an SDK `addToolFilter` that evaluates discovery visibility from each request's `McpTransportContext`. `PerRequestToolFilteringHandler` adjusts organization-specific schemas in the SDK list response and enforces the same restrictions on direct `tools/call` requests. The SDK filter controls discovery only; direct-call authorization remains required. Checks run in this order: tool existence and ordinary category/read-only restrictions, OAuth scopes, per-tool enablement, then dispatch. Unknown or ordinarily filtered tools retain their existing errors.

OAuth scopes are independent of the `SONARQUBE_READ_ONLY` preference and are not client-supplied headers or published tool-schema fields. Tool requirements use Token B's Cloud scope names internally:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
import org.sonarsource.sonarqube.mcp.tools.webhooks.ListWebhooksTool;
import org.sonarsource.sonarqube.mcp.transport.HttpServerTransportProvider;
import org.sonarsource.sonarqube.mcp.transport.HttpTransportSettings;
import org.sonarsource.sonarqube.mcp.transport.PerRequestToolFilteringHandler;
import org.sonarsource.sonarqube.mcp.transport.StdioInitializeErrorReporter;
import org.sonarsource.sonarqube.mcp.transport.StdioServerTransportProvider;

Expand Down Expand Up @@ -262,6 +263,7 @@ public void start() {
.serverInfo(McpSchema.Implementation.builder(SONARQUBE_MCP_SERVER_NAME, mcpConfiguration.getAppVersion()).build())
.instructions(composedInstructions)
.capabilities(McpSchema.ServerCapabilities.builder().tools(true).build())
.addToolFilter(PerRequestToolFilteringHandler.toolFilter(enabledTools))
.tools(enabledTools.stream().map(this::toStatelessSpec).toArray(McpStatelessServerFeatures.SyncToolSpecification[]::new))
.build();
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@

import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.server.McpStatelessServerHandler;
import io.modelcontextprotocol.server.McpSyncListFilter;
import io.modelcontextprotocol.spec.McpSchema;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import jakarta.annotation.Nullable;
import org.sonarsource.sonarqube.mcp.log.McpLogger;
import org.sonarsource.sonarqube.mcp.tools.Tool;
Expand All @@ -45,10 +48,8 @@
* was launched with.</li>
* </ul>
*
* <p>The SDK's built-in {@code toolsListRequestHandler} ignores the {@link McpTransportContext},
* so it always returns all registered tools. When a per-request filter is active, this handler
* short-circuits the delegate and directly returns only the subset the client is allowed to see,
* without consulting the SDK handler at all.
* <p>The SDK applies {@link #toolFilter(List)} to discovery. This wrapper only adjusts
* organization-specific schemas in the SDK response and enforces direct-call authorization.
*
* <p>For {@code tools/call}, if a per-request filter is active and the requested tool is not in
* the allowed set, a {@code METHOD_NOT_FOUND} error is returned immediately without delegating
Expand Down Expand Up @@ -86,9 +87,7 @@ public PerRequestToolFilteringHandler(McpStatelessServerHandler delegate, List<T
@Override
public Mono<McpSchema.JSONRPCResponse> handleRequest(McpTransportContext transportContext, McpSchema.JSONRPCRequest request) {
if (McpSchema.METHOD_TOOLS_LIST.equals(request.method())) {
var filteredTools = filterTools(transportContext);
var result = new McpSchema.ListToolsResult(filteredTools, null);
return Mono.just(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), result, null));
return delegate.handleRequest(transportContext, request).map(response -> adjustOrganizationSchema(transportContext, response));
}
if (McpSchema.METHOD_TOOLS_CALL.equals(request.method())) {
return handleToolCall(transportContext, request);
Expand Down Expand Up @@ -128,18 +127,26 @@ private Mono<McpSchema.JSONRPCResponse> handleToolCall(McpTransportContext trans
return delegate.handleRequest(transportContext, request);
}

private List<McpSchema.Tool> filterTools(McpTransportContext ctx) {
var authorization = (OAuthScopeContext) ctx.get(OAuthScopeContext.CONTEXT_KEY);
var organization = ctx.get(HttpServerTransportProvider.CONTEXT_ORG_KEY);
var organizationFixed = organization instanceof String key && !key.isBlank();

return allTools.stream()
.filter(tool -> matchesRequestFilters(tool, ctx))
.filter(tool -> authorization == null || authorization.permits(tool.requiredAccessMode()))
.filter(tool -> tool.isEnabledFor(ctx))
.map(Tool::definition)
.map(definition -> organizationFixed ? ToolDefinitionBuilder.withoutOrganization(definition) : definition)
.toList();
public static McpSyncListFilter<McpSchema.Tool> toolFilter(List<Tool> enabledTools) {
var toolsByName = enabledTools.stream().collect(Collectors.toUnmodifiableMap(tool -> tool.definition().name(), Function.identity()));
return (context, definition) -> {
var tool = toolsByName.get(definition.name());
var authorization = (OAuthScopeContext) context.get(OAuthScopeContext.CONTEXT_KEY);
return tool != null && matchesRequestFilters(tool, context)
&& (authorization == null || authorization.permits(tool.requiredAccessMode()))
&& tool.isEnabledFor(context);
};
}
Comment thread
gitar-bot[bot] marked this conversation as resolved.
Resolved

private static McpSchema.JSONRPCResponse adjustOrganizationSchema(McpTransportContext context, McpSchema.JSONRPCResponse response) {
var organization = context.get(HttpServerTransportProvider.CONTEXT_ORG_KEY);
if (response.error() != null || !(organization instanceof String key) || key.isBlank()
|| !(response.result() instanceof McpSchema.ListToolsResult(var definitions, var nextCursor, var meta))) {
return response;
}
var tools = definitions.stream().map(ToolDefinitionBuilder::withoutOrganization).toList();
return new McpSchema.JSONRPCResponse(response.jsonrpc(), response.id(),
new McpSchema.ListToolsResult(tools, nextCursor, meta), response.error());
}

private static boolean matchesRequestFilters(Tool tool, McpTransportContext ctx) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ public List<String> protocolVersions() {
}
};
mcp = McpServer.sync(filtering).serverInfo("scope-tests", "1")
.addToolFilter(PerRequestToolFilteringHandler.toolFilter(tools))
.capabilities(McpSchema.ServerCapabilities.builder().tools(true).build())
.tools(tools.stream().map(tool -> new McpStatelessServerFeatures.SyncToolSpecification.Builder().tool(tool.definition())
.callHandler((context, request) -> tool.execute(new Tool.Arguments(request.arguments(), null)).toCallToolResult()).build())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,20 @@
package org.sonarsource.sonarqube.mcp.transport;

import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.McpStatelessServerFeatures;
import io.modelcontextprotocol.server.McpStatelessServerHandler;
import io.modelcontextprotocol.server.McpStatelessSyncServer;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpStatelessServerTransport;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Stream;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
Expand All @@ -40,11 +48,17 @@
class PerRequestToolFilteringHandlerTest {

private static final String REQUEST_ID = "1";
private final List<McpStatelessSyncServer> servers = new ArrayList<>();

@AfterEach
void closeServers() {
servers.forEach(McpStatelessSyncServer::close);
}

@Test
void tools_list_always_returns_filtered_list() {
var issuesTool = mockTool("search_issues", ToolCategory.ISSUES, true);
var handler = new PerRequestToolFilteringHandler(mock(McpStatelessServerHandler.class), List.of(issuesTool));
var handler = sdkHandler(List.of(issuesTool));
var context = contextWithToken();

var response = handler.handleRequest(context, toolsListRequest()).block();
Expand All @@ -59,7 +73,7 @@ void tools_list_with_toolsets_header_returns_only_matching_tools() {
var hotspotsTool = mockTool("search_hotspots", ToolCategory.SECURITY_HOTSPOTS, true);
var projectsTool = mockTool("search_projects", ToolCategory.PROJECTS, true);

var handler = new PerRequestToolFilteringHandler(mock(McpStatelessServerHandler.class), List.of(issuesTool, hotspotsTool, projectsTool));
var handler = sdkHandler(List.of(issuesTool, hotspotsTool, projectsTool));
var context = contextWith(Map.of(
HttpServerTransportProvider.CONTEXT_TOKEN_KEY, "token",
HttpServerTransportProvider.CONTEXT_TOOLSETS_KEY, Set.of(ToolCategory.ISSUES)
Expand All @@ -77,7 +91,7 @@ void tools_list_with_read_only_header_returns_only_read_only_tools() {
var readOnlyTool = mockTool("search_issues", ToolCategory.ISSUES, true);
var writeTool = mockTool("change_status", ToolCategory.ISSUES, false);

var handler = new PerRequestToolFilteringHandler(mock(McpStatelessServerHandler.class), List.of(readOnlyTool, writeTool));
var handler = sdkHandler(List.of(readOnlyTool, writeTool));
var context = contextWith(Map.of(
HttpServerTransportProvider.CONTEXT_TOKEN_KEY, "token",
HttpServerTransportProvider.CONTEXT_READ_ONLY_KEY, true
Expand All @@ -96,7 +110,7 @@ void tools_list_with_read_only_header_excludes_tools_without_an_explicit_true_hi
var emptyAnnotations = mockTool("empty_annotations", ToolCategory.ISSUES, new McpSchema.ToolAnnotations(null, null, null, null, null, null));
var falseHint = mockTool("false_hint", ToolCategory.ISSUES, false);
var trueHint = mockTool("true_hint", ToolCategory.ISSUES, true);
var handler = new PerRequestToolFilteringHandler(mock(McpStatelessServerHandler.class), List.of(noAnnotations, emptyAnnotations, falseHint, trueHint));
var handler = sdkHandler(List.of(noAnnotations, emptyAnnotations, falseHint, trueHint));
var context = contextWith(Map.of(HttpServerTransportProvider.CONTEXT_READ_ONLY_KEY, true));

var result = (McpSchema.ListToolsResult) handler.handleRequest(context, toolsListRequest()).block().result();
Expand All @@ -110,7 +124,7 @@ void tools_list_with_toolsets_and_read_only_applies_both_filters() {
var issuesWrite = mockTool("change_status", ToolCategory.ISSUES, false);
var hotspotsTool = mockTool("search_hotspots", ToolCategory.SECURITY_HOTSPOTS, true);

var handler = new PerRequestToolFilteringHandler(mock(McpStatelessServerHandler.class),
var handler = sdkHandler(
List.of(issuesReadOnly, issuesWrite, hotspotsTool));
var context = contextWith(Map.of(
HttpServerTransportProvider.CONTEXT_TOKEN_KEY, "token",
Expand All @@ -130,7 +144,7 @@ void tools_list_projects_category_always_included_regardless_of_toolsets() {
var projectsTool = mockTool("search_projects", ToolCategory.PROJECTS, true);
var issuesTool = mockTool("search_issues", ToolCategory.ISSUES, true);

var handler = new PerRequestToolFilteringHandler(mock(McpStatelessServerHandler.class), List.of(projectsTool, issuesTool));
var handler = sdkHandler(List.of(projectsTool, issuesTool));
var context = contextWith(Map.of(
HttpServerTransportProvider.CONTEXT_TOKEN_KEY, "token",
HttpServerTransportProvider.CONTEXT_TOOLSETS_KEY, Set.of(ToolCategory.MEASURES) // neither issues nor projects
Expand All @@ -150,7 +164,7 @@ void tools_list_excludes_tool_when_is_enabled_for_returns_false() {
var context = contextWithToken();
when(disabledTool.isEnabledFor(context)).thenReturn(false);

var handler = new PerRequestToolFilteringHandler(mock(McpStatelessServerHandler.class), List.of(enabledTool, disabledTool));
var handler = sdkHandler(List.of(enabledTool, disabledTool));

var result = (McpSchema.ListToolsResult) handler.handleRequest(context, toolsListRequest()).block().result();

Expand Down Expand Up @@ -396,6 +410,88 @@ void tools_call_with_invalid_params_delegates_to_sdk_handler(Object params) {
verify(delegate).handleRequest(contextWithToken(), request);
}

@Test
void it_should_delegate_listing_and_preserve_sdk_metadata_when_adjusting_schemas() {
var definition = McpSchema.Tool.builder("organization_tool", Map.of(
"type", "object", "properties", Map.of("organization", Map.of("type", "string")), "required", List.of("organization"))).build();
var result = new McpSchema.ListToolsResult(List.of(definition), "next-page", Map.of("source", "sdk"));
var response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, REQUEST_ID, result, null);
var delegate = mock(McpStatelessServerHandler.class);
when(delegate.handleRequest(any(), any())).thenReturn(Mono.just(response));
var handler = new PerRequestToolFilteringHandler(delegate, List.of());
var request = toolsListRequest();
var context = contextWith(Map.of(HttpServerTransportProvider.CONTEXT_ORG_KEY, "fixed-org"));

var adjusted = handler.handleRequest(context, request).block();

verify(delegate).handleRequest(context, request);
var adjustedResult = (McpSchema.ListToolsResult) adjusted.result();
assertThat(((Map<?, ?>) adjustedResult.tools().getFirst().inputSchema().get("properties")).containsKey("organization")).isFalse();
assertThat(adjustedResult.nextCursor()).isEqualTo("next-page");
assertThat(adjustedResult.meta()).containsEntry("source", "sdk");
assertThat(adjusted.id()).isEqualTo(response.id());
assertThat(handler.handleRequest(contextWithToken(), request).block()).isSameAs(response);
}

@Test
void it_should_preserve_sdk_listing_errors_with_fixed_organization() {
var response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, REQUEST_ID, null,
new McpSchema.JSONRPCResponse.JSONRPCError(-32602, "Invalid cursor", null));
var delegate = mock(McpStatelessServerHandler.class);
when(delegate.handleRequest(any(), any())).thenReturn(Mono.just(response));
var handler = new PerRequestToolFilteringHandler(delegate, List.of());

assertThat(handler.handleRequest(contextWith(Map.of(HttpServerTransportProvider.CONTEXT_ORG_KEY, "fixed-org")), toolsListRequest()).block())
.isSameAs(response);
}

@Test
void it_should_isolate_sdk_discovery_filters_between_concurrent_requests() {
var readTool = mockTool("read_tool", ToolCategory.ISSUES, true);
var writeTool = mockTool("write_tool", ToolCategory.SECURITY_HOTSPOTS, false);
var handler = sdkHandler(List.of(readTool, writeTool));
var restricted = contextWith(Map.of(HttpServerTransportProvider.CONTEXT_READ_ONLY_KEY, true));
var unrestricted = contextWithToken();
var requests = Stream.generate(() -> CompletableFuture.runAsync(() -> {
var visible = (McpSchema.ListToolsResult) handler.handleRequest(restricted, toolsListRequest()).block().result();
assertThat(visible.tools()).extracting(McpSchema.Tool::name).containsExactly("read_tool");
var all = (McpSchema.ListToolsResult) handler.handleRequest(unrestricted, toolsListRequest()).block().result();
assertThat(all.tools()).extracting(McpSchema.Tool::name).containsExactlyInAnyOrder("read_tool", "write_tool");
assertThat(handler.handleRequest(restricted, toolsCallRequest("write_tool")).block().error().code())
.isEqualTo(McpSchema.ErrorCodes.METHOD_NOT_FOUND);
})).limit(20).toArray(CompletableFuture[]::new);

CompletableFuture.allOf(requests).join();
}

private PerRequestToolFilteringHandler sdkHandler(List<Tool> tools) {
var delegate = new AtomicReference<McpStatelessServerHandler>();
var transport = new McpStatelessServerTransport() {
@Override
public void setMcpHandler(McpStatelessServerHandler handler) {
delegate.set(handler);
}

@Override
public Mono<Void> closeGracefully() {
return Mono.empty();
}

@Override
public List<String> protocolVersions() {
return List.of("2025-03-26");
}
};
servers.add(McpServer.sync(transport).serverInfo("filter-tests", "1")
.capabilities(McpSchema.ServerCapabilities.builder().tools(true).build())
.addToolFilter(PerRequestToolFilteringHandler.toolFilter(tools))
.tools(tools.stream().map(tool -> new McpStatelessServerFeatures.SyncToolSpecification.Builder()
.tool(tool.definition()).callHandler((context, request) -> McpSchema.CallToolResult.builder().build()).build())
.toArray(McpStatelessServerFeatures.SyncToolSpecification[]::new))
.build());
return new PerRequestToolFilteringHandler(delegate.get(), tools);
}

private static Stream<Object> invalidToolCallParams() {
return Stream.of(null, "not-a-map", Map.of(), Map.of("name", 42));
}
Expand Down
Loading