Skip to content

Commit b526cc0

Browse files
authored
MCP-176 Provide structured content for tool's results (#127)
1 parent 029fc71 commit b526cc0

File tree

83 files changed

+5993
-2376
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

83 files changed

+5993
-2376
lines changed

build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ dependencies {
8686
implementation(libs.ayza)
8787
implementation(libs.jetty.server)
8888
implementation(libs.jetty.ee10.servlet)
89+
implementation(libs.jsonschema.generator)
90+
implementation(libs.jsonschema.module.jackson)
8991
runtimeOnly(libs.logback.classic)
9092
testImplementation(platform(libs.junit.bom))
9193
testImplementation(libs.junit.jupiter)

gradle/libs.versions.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ commons-text = "1.14.0"
1515
ayza = "10.0.0"
1616
logback = "1.5.20"
1717
jetty = "12.1.3"
18+
jsonschema-generator = "4.38.0"
1819

1920
junit = "5.14.0"
2021
junit-launcher = "1.14.0"
@@ -35,6 +36,8 @@ commons-langs3 = { module = "org.apache.commons:commons-lang3", version.ref = "c
3536
commons-text = { module = "org.apache.commons:commons-text", version.ref = "commons-text" }
3637
ayza = { module = "io.github.hakky54:ayza", version.ref = "ayza" }
3738
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
39+
jsonschema-generator = { module = "com.github.victools:jsonschema-generator", version.ref = "jsonschema-generator" }
40+
jsonschema-module-jackson = { module = "com.github.victools:jsonschema-module-jackson", version.ref = "jsonschema-generator" }
3841

3942
jetty-server = { module = "org.eclipse.jetty:jetty-server", version.ref = "jetty" }
4043
jetty-ee10-servlet = { module = "org.eclipse.jetty.ee10:jetty-ee10-servlet", version.ref = "jetty" }

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

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import io.modelcontextprotocol.spec.McpSchema;
2020
import java.util.ArrayList;
21+
import java.util.Collections;
2122
import java.util.HashMap;
2223
import java.util.List;
2324
import java.util.Map;
@@ -29,18 +30,23 @@ public class SchemaToolBuilder {
2930
private static final String ITEMS_PROPERTY_NAME = "items";
3031
private final Map<String, Object> properties;
3132
private final List<String> requiredProperties;
32-
private final Map<String, Object> def;
33-
private final Map<String, Object> definitions;
33+
private final Map<String, Object> outputSchemaFromClass;
3434
private String name;
3535
private String title;
3636
private String description;
37-
private boolean additionalProperties;
3837

39-
public SchemaToolBuilder() {
38+
public SchemaToolBuilder(Map<String, Object> outputSchemaFromClass) {
4039
this.properties = new HashMap<>();
4140
this.requiredProperties = new ArrayList<>();
42-
this.def = new HashMap<>();
43-
this.definitions = new HashMap<>();
41+
this.outputSchemaFromClass = outputSchemaFromClass;
42+
}
43+
44+
/**
45+
* Factory method to create a SchemaToolBuilder with automatic output schema generation from a class.
46+
* This is the recommended approach for defining structured output.
47+
*/
48+
public static SchemaToolBuilder forOutput(Class<? extends Record> outputClass) {
49+
return new SchemaToolBuilder(SchemaUtils.generateOutputSchema(outputClass));
4450
}
4551

4652
public SchemaToolBuilder setName(String name) {
@@ -109,8 +115,8 @@ public McpSchema.Tool build() {
109115
throw new IllegalStateException("Cannot set a required property that does not exist.");
110116
}
111117

112-
var jsonSchema = new McpSchema.JsonSchema("object", properties, requiredProperties, additionalProperties, def, definitions);
118+
var jsonSchema = new McpSchema.JsonSchema("object", properties, requiredProperties, false, Collections.emptyMap(), Collections.emptyMap());
113119

114-
return new McpSchema.Tool(name, title, description, jsonSchema, null, null, Map.of());
120+
return new McpSchema.Tool(name, title, description, jsonSchema, outputSchemaFromClass, null, Map.of());
115121
}
116122
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
* SonarQube MCP Server
3+
* Copyright (C) 2025 SonarSource
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
package org.sonarsource.sonarqube.mcp.tools;
18+
19+
import com.fasterxml.jackson.annotation.JsonInclude;
20+
import com.fasterxml.jackson.databind.ObjectMapper;
21+
import com.github.victools.jsonschema.generator.OptionPreset;
22+
import com.github.victools.jsonschema.generator.SchemaGenerator;
23+
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
24+
import com.github.victools.jsonschema.generator.SchemaVersion;
25+
import com.github.victools.jsonschema.module.jackson.JacksonModule;
26+
import com.github.victools.jsonschema.module.jackson.JacksonOption;
27+
import jakarta.annotation.Nullable;
28+
import java.util.Map;
29+
30+
/**
31+
* Utility class for generating JSON schemas from Java classes and serializing objects.
32+
* Uses jsonschema-generator library for schema generation.
33+
*/
34+
public class SchemaUtils {
35+
36+
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()
37+
.setSerializationInclusion(JsonInclude.Include.NON_NULL);
38+
39+
private static final SchemaGenerator SCHEMA_GENERATOR;
40+
41+
static {
42+
var configBuilder = new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON)
43+
.with(new JacksonModule(JacksonOption.RESPECT_JSONPROPERTY_REQUIRED));
44+
configBuilder.forFields()
45+
.withRequiredCheck(field -> field.getAnnotationConsideringFieldAndGetter(Nullable.class) == null);
46+
configBuilder.forMethods()
47+
.withRequiredCheck(method -> method.getAnnotationConsideringFieldAndGetter(Nullable.class) == null);
48+
SCHEMA_GENERATOR = new SchemaGenerator(configBuilder.build());
49+
}
50+
51+
private SchemaUtils() {
52+
// Static class
53+
}
54+
55+
@SuppressWarnings("unchecked")
56+
public static Map<String, Object> generateOutputSchema(Class<? extends Record> clazz) {
57+
var schemaNode = SCHEMA_GENERATOR.generateSchema(clazz);
58+
var schema = OBJECT_MAPPER.convertValue(schemaNode, Map.class);
59+
schema.remove("$schema");
60+
return schema;
61+
}
62+
63+
@SuppressWarnings("unchecked")
64+
public static Map<String, Object> toStructuredContent(Record obj) {
65+
return OBJECT_MAPPER.convertValue(obj, Map.class);
66+
}
67+
68+
public static String toJsonString(Record response) {
69+
try {
70+
return OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(response);
71+
} catch (Exception e) {
72+
throw new IllegalStateException("Failed to convert response to JSON string", e);
73+
}
74+
}
75+
76+
}
77+

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,17 @@ public List<String> getOptionalStringList(String argumentName) {
120120
}
121121

122122
public static class Result {
123-
public static Result success(String content) {
124-
return new Result(McpSchema.CallToolResult.builder().isError(false).addTextContent(content).build());
123+
/**
124+
* Create a successful result from a response object.
125+
* The response object will be serialized to both JSON text content and structured content.
126+
* This follows the MCP spec recommendation that structured content should also be available as text.
127+
*/
128+
public static Result success(Record responseObject) {
129+
return new Result(McpSchema.CallToolResult.builder()
130+
.isError(false)
131+
.addTextContent(SchemaUtils.toJsonString(responseObject))
132+
.structuredContent(SchemaUtils.toStructuredContent(responseObject))
133+
.build());
125134
}
126135

127136
public static Result failure(String errorMessage) {

src/main/java/org/sonarsource/sonarqube/mcp/tools/analysis/AnalysisTool.java

Lines changed: 29 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public class AnalysisTool extends Tool {
5050
private final ServerApiProvider serverApiProvider;
5151

5252
public AnalysisTool(BackendService backendService, ServerApiProvider serverApiProvider) {
53-
super(new SchemaToolBuilder()
53+
super(SchemaToolBuilder.forOutput(AnalysisToolResponse.class)
5454
.setName(TOOL_NAME)
5555
.setTitle("Code File Analysis")
5656
.setDescription("Analyze a file or code snippet with SonarQube analyzers to identify code quality and security issues. " +
@@ -86,7 +86,8 @@ public Result execute(Arguments arguments) {
8686
var startTime = System.currentTimeMillis();
8787
var response = backendService.analyzeFilesAndTrack(analysisId, List.of(tmpFile.toUri()), startTime).get(30,
8888
TimeUnit.SECONDS);
89-
return Tool.Result.success(buildResponseFromAnalysisResults(response));
89+
var toolResponse = buildStructuredContent(response);
90+
return Tool.Result.success(toolResponse);
9091
} catch (IOException | ExecutionException | TimeoutException e) {
9192
return Tool.Result.failure("Error while analyzing the code snippet: " + e.getMessage());
9293
} catch (InterruptedException e) {
@@ -122,49 +123,6 @@ private void applyRulesFromProject(@Nullable String projectKey) {
122123
backendService.updateRulesConfiguration(activeRules);
123124
}
124125

125-
private static String buildResponseFromAnalysisResults(AnalyzeFilesResponse response) {
126-
var stringBuilder = new StringBuilder();
127-
128-
if (!response.getFailedAnalysisFiles().isEmpty()) {
129-
stringBuilder.append("Failed to analyze the code snippet.");
130-
return stringBuilder.toString();
131-
}
132-
133-
if (response.getRawIssues().isEmpty()) {
134-
stringBuilder.append("No Sonar issues found in the code snippet.");
135-
} else {
136-
stringBuilder.append("Found ").append(response.getRawIssues().size()).append(" Sonar issues in the code snippet");
137-
138-
for (var issue : response.getRawIssues()) {
139-
stringBuilder.append("\n");
140-
stringBuilder.append(issue.getPrimaryMessage());
141-
stringBuilder.append("\n");
142-
stringBuilder.append("Rule key: ").append(issue.getRuleKey());
143-
stringBuilder.append("\n");
144-
stringBuilder.append("Severity: ").append(issue.getSeverity());
145-
stringBuilder.append("\n");
146-
stringBuilder.append("Clean Code attribute: ").append(issue.getCleanCodeAttribute().name());
147-
stringBuilder.append("\n");
148-
stringBuilder.append("Impacts: ").append(issue.getImpacts().toString());
149-
stringBuilder.append("\n");
150-
stringBuilder.append("Description: ").append(issue.getPrimaryMessage());
151-
stringBuilder.append("\n");
152-
stringBuilder.append("Quick fixes available: ").append(issue.getQuickFixes().isEmpty() ? "No" : "Yes");
153-
154-
var textRange = issue.getTextRange();
155-
if (textRange != null) {
156-
stringBuilder.append("\n");
157-
stringBuilder.append("Starting on line: ").append(textRange.getStartLine());
158-
}
159-
}
160-
}
161-
162-
stringBuilder.append("\nDisclaimer: Analysis results might not be fully accurate as the code snippet is not part of a complete project context." +
163-
" Use SonarQube for IDE for better results, or setup a full project analysis in SonarQube Server or Cloud.");
164-
165-
return stringBuilder.toString().trim();
166-
}
167-
168126
private static Path createTemporaryFileForLanguage(String analysisId, Path workDir, String fileContent, SonarLanguage language) throws IOException {
169127
var defaultFileSuffixes = language.getDefaultFileSuffixes();
170128
var extension = defaultFileSuffixes.length > 0 ? defaultFileSuffixes[0] : "";
@@ -180,4 +138,30 @@ private static void removeTmpFileForAnalysis(Path tempFile) throws IOException {
180138
Files.deleteIfExists(tempFile);
181139
}
182140

141+
public AnalysisToolResponse buildStructuredContent(AnalyzeFilesResponse response) {
142+
var issues = response.getRawIssues().stream()
143+
.map(issue -> {
144+
AnalysisToolResponse.TextRange textRange = null;
145+
if (issue.getTextRange() != null) {
146+
textRange = new AnalysisToolResponse.TextRange(
147+
issue.getTextRange().getStartLine(),
148+
issue.getTextRange().getEndLine()
149+
);
150+
}
151+
152+
return new AnalysisToolResponse.Issue(
153+
issue.getRuleKey(),
154+
issue.getPrimaryMessage(),
155+
issue.getSeverity().toString(),
156+
issue.getCleanCodeAttribute().name(),
157+
issue.getImpacts().toString(),
158+
!issue.getQuickFixes().isEmpty(),
159+
textRange
160+
);
161+
})
162+
.toList();
163+
164+
return new AnalysisToolResponse(issues, response.getRawIssues().size());
165+
}
166+
183167
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* SonarQube MCP Server
3+
* Copyright (C) 2025 SonarSource
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
package org.sonarsource.sonarqube.mcp.tools.analysis;
18+
import jakarta.annotation.Nullable;
19+
20+
import com.fasterxml.jackson.annotation.JsonInclude;
21+
import java.util.List;
22+
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
23+
24+
@JsonInclude(JsonInclude.Include.NON_NULL)
25+
public record AnalysisToolResponse(
26+
@JsonPropertyDescription("List of issues found in the code snippet") List<Issue> issues,
27+
@JsonPropertyDescription("Total number of issues") int issueCount
28+
) {
29+
30+
public record Issue(
31+
@JsonPropertyDescription("Rule key that triggered the issue") String ruleKey,
32+
@JsonPropertyDescription("Primary issue message") String primaryMessage,
33+
@JsonPropertyDescription("Issue severity level") String severity,
34+
@JsonPropertyDescription("Clean code attribute") String cleanCodeAttribute,
35+
@JsonPropertyDescription("Software quality impacts") String impacts,
36+
@JsonPropertyDescription("Whether quick fixes are available") boolean hasQuickFixes,
37+
@JsonPropertyDescription("Location in the code") @Nullable TextRange textRange
38+
) {}
39+
40+
public record TextRange(
41+
@JsonPropertyDescription("Starting line number") int startLine,
42+
@JsonPropertyDescription("Ending line number") int endLine
43+
) {}
44+
}
45+

src/main/java/org/sonarsource/sonarqube/mcp/tools/analysis/AnalyzeFileListTool.java

Lines changed: 18 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public class AnalyzeFileListTool extends Tool {
3030
private final SonarQubeIdeBridgeClient bridgeClient;
3131

3232
public AnalyzeFileListTool(SonarQubeIdeBridgeClient bridgeClient) {
33-
super(new SchemaToolBuilder()
33+
super(SchemaToolBuilder.forOutput(AnalyzeFileListToolResponse.class)
3434
.setName(TOOL_NAME)
3535
.setTitle("Multiple File Analysis")
3636
.setDescription("Analyze files in the current working directory using SonarQube for IDE. " +
@@ -59,50 +59,27 @@ public Result execute(Arguments arguments) {
5959
}
6060

6161
var results = analysisResult.get();
62-
var issuesSummary = formatAnalysisResults(results);
62+
var toolResponse = buildStructuredContent(results);
6363

6464
LOG.info("Returning success result to MCP client");
65-
return Result.success(issuesSummary);
65+
return Result.success(toolResponse);
6666
}
6767

68-
private static String formatAnalysisResults(SonarQubeIdeBridgeClient.AnalyzeFileListResponse results) {
69-
var sb = new StringBuilder();
70-
71-
sb.append("SonarQube for IDE Analysis Completed!\n\n");
72-
sb.append("Analysis Summary:\n");
73-
74-
if (results.findings().isEmpty()) {
75-
sb.append("No findings found! Your code looks good.\n\n");
76-
} else {
77-
var findings = results.findings();
78-
sb.append("Issues Found (").append(findings.size()).append("):\n");
79-
// Show max 100 issues
80-
for (int i = 0; i < Math.min(100, findings.size()); i++) {
81-
var issue = findings.get(i);
82-
sb.append(" ").append(i + 1).append(". ").append(formatFinding(issue)).append("\n");
83-
}
84-
if (findings.size() > 100) {
85-
sb.append(" ... and ").append(findings.size() - 100).append(" more issues\n");
86-
}
87-
}
88-
89-
sb.append("Next Steps:\n");
90-
sb.append("Check SonarQube for IDE - issues are now displayed in your extension\n");
91-
sb.append("Ask the agent to fix the issues.");
92-
93-
return sb.toString();
94-
}
95-
96-
private static String formatFinding(SonarQubeIdeBridgeClient.AnalyzeFileListIssueResponse issue) {
97-
var textRange = issue.textRange();
98-
if (textRange == null) {
99-
return String.format("[%s] %s (file: %s)",
100-
issue.severity(), issue.message(), issue.filePath());
101-
} else {
102-
return String.format("[%s] %s (file: %s [Lines: %d to %d])",
103-
issue.severity(), issue.message(), issue.filePath(),
104-
issue.textRange().getStartLine(), issue.textRange().getEndLine());
105-
}
68+
private static AnalyzeFileListToolResponse buildStructuredContent(SonarQubeIdeBridgeClient.AnalyzeFileListResponse results) {
69+
var findings = results.findings().stream()
70+
.map(f -> {
71+
AnalyzeFileListToolResponse.TextRange textRange = null;
72+
if (f.textRange() != null) {
73+
textRange = new AnalyzeFileListToolResponse.TextRange(
74+
f.textRange().getStartLine(),
75+
f.textRange().getEndLine()
76+
);
77+
}
78+
return new AnalyzeFileListToolResponse.Finding(f.severity(), f.message(), f.filePath(), textRange);
79+
})
80+
.toList();
81+
82+
return new AnalyzeFileListToolResponse(findings, results.findings().size());
10683
}
10784

10885
}

0 commit comments

Comments
 (0)