Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.ListToolsResult;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -20,7 +24,6 @@ public class BraveSearchMcpService {
private static final String BRAVE_WEB_TOOL = "brave_web_search";

private final List<McpSyncClient> mcpClients;

private final ObjectMapper objectMapper;

public JsonNode search(String query, int count, int offset) {
Expand All @@ -33,22 +36,68 @@ public JsonNode search(String query, int count, int offset) {

CallToolResult result = braveClient.callTool(request);

JsonNode content = objectMapper.valueToTree(result.content());
log.info("[Brave MCP Response Raw content]: {}", content.toPrettyString());
// 원본 로그 (디버그용)
JsonNode raw = objectMapper.valueToTree(result.content());
log.info("[Brave MCP Response Raw content]: {}", raw.toPrettyString());

// 결과 정규화: results 배열에 {url,title,description}
ArrayNode results = objectMapper.createArrayNode();

if (raw != null && raw.isArray()) {
for (JsonNode item : raw) {
// MCP content 중 text 타입만 처리
if ("text".equalsIgnoreCase(item.path("type").asText())) {
String text = item.path("text").asText("").trim();
if (text.isEmpty()) {
continue;
}

Comment on lines +67 to 69
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

과도한 원본 응답 로그는 크기/민감도 리스크가 있습니다 — 로그 레벨 하향 및 트렁케이션 제안

대용량/다중 중첩 응답이 그대로 info 로그에 남습니다. 디버깅 구간에서만 필요하므로 debug 레벨과 길이 제한을 권장합니다.

아래와 같이 수정을 제안합니다.

-        JsonNode raw = objectMapper.valueToTree(result.content());
-        log.info("[Brave MCP Response Raw content]: {}", raw);
+        JsonNode raw = objectMapper.valueToTree(result.content());
+        log.debug("[Brave MCP Response Raw content]: {}",
+            truncate(raw != null ? raw.toString() : "null", 2000));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
JsonNode raw = objectMapper.valueToTree(result.content());
log.info("[Brave MCP Response Raw content]: {}", raw);
JsonNode raw = objectMapper.valueToTree(result.content());
log.debug("[Brave MCP Response Raw content]: {}",
truncate(raw != null ? raw.toString() : "null", 2000));
🤖 Prompt for AI Agents
In
cs25-service/src/main/java/com/example/cs25service/domain/ai/service/BraveSearchMcpService.java
around lines 67-69, the code currently logs the full raw JsonNode at info level
which risks large and sensitive output; change this to emit at debug level and
truncate the serialized JSON to a safe max length (e.g. 1000 chars) to avoid
huge logs and leakage. Implement by checking logger.isDebugEnabled() before
serializing, convert the JsonNode to a string, truncate it to the configured max
length (append "…(truncated)" when shortened), and then call log.debug with the
truncated string instead of log.info; ensure no heavy work runs when debug
logging is disabled.

// 1) 우선 JSON으로 파싱 시도 (대부분 여기서 해결)
if (looksLikeJson(text)) {
parseJsonBlockIntoResults(text, results);
continue;
}

if (content != null && content.isArray()) {
var root = objectMapper.createObjectNode();
root.set("results", content);
return root;
// 2) 혹시 text가 "{"url":...}" 같은 JSON 문자열 리터럴로 들어온 경우
// readTree하면 TextNode가 나오니 한 번 더 벗겨서 재파싱
try {
JsonNode maybeString = objectMapper.readTree(text);
if (maybeString.isTextual() && looksLikeJson(maybeString.asText())) {
parseJsonBlockIntoResults(maybeString.asText(), results);
continue;
}
} catch (Exception ignored) {
// 그냥 패스하고 fallback로
}

// 3) Fallback: "Title:..., URL:..., (내용...)" 포맷 처리
addFromTitleUrlBlock(text, results);
}
}
}

return content != null ? content : objectMapper.createObjectNode();
ObjectNode root = objectMapper.createObjectNode();
root.set("results", results);
log.info("Brave 검색 결과 {}건 추출", results.size());
return root;
}

private McpSyncClient resolveBraveClient() {
for (McpSyncClient client : mcpClients) {
ListToolsResult tools = client.listTools();
ListToolsResult tools;
try {
tools = client.listTools();
} catch (McpError e) {
// 초기화 안 된 클라이언트 방어
if (e.getMessage() != null &&
e.getMessage().toLowerCase(Locale.ROOT).contains("initialized")) {
client.initialize();
tools = client.listTools();
} else {
throw e;
}
}

if (tools != null && tools.tools() != null) {
boolean found = tools.tools().stream()
.anyMatch(tool -> BRAVE_WEB_TOOL.equalsIgnoreCase(tool.name()));
Expand All @@ -57,7 +106,111 @@ private McpSyncClient resolveBraveClient() {
}
}
}
throw new IllegalStateException("Brave MCP 서버에서 " + BRAVE_WEB_TOOL + " 툴을 찾을 수 없습니다.");
}

/* ------------------ helpers ------------------ */

Comment on lines +139 to +161
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

normalizeOne가 객체 루트의 "results" 배열을 직접 처리하지 않습니다

현재는 문자열 JSON으로 들어온 경우(parseJsonBlockIntoResults)만 루트에 results 배열을 순회합니다. 실제로 raw가 이미 객체(JSON)인 경우에도 results 배열을 지원하도록 분기를 추가해야 누락이 없습니다.

아래와 같이 분기를 추가해 주세요.

     private void normalizeOne(JsonNode node, ArrayNode out) {
         if (node == null || node.isNull()) {
             return;
         }

         // 이미 {url,title,description} 형태
         if (node.isObject() && (node.has("url") || node.has("title") || node.has("description"))) {
             addObjectToResults((ObjectNode) node, out);
             return;
         }
 
+        // 루트에 results 배열이 있는 객체 형태 처리
+        if (node.isObject() && node.has("results") && node.get("results").isArray()) {
+            for (JsonNode n : node.get("results")) {
+                normalizeOne(n, out);
+            }
+            return;
+        }
+
         // MCP가 주는 content item: { "type":"text", "text":"{...json...}" } 같은 형태를 방어
         if (node.isObject() && node.has("text")) {
             String text = node.path("text").asText("");
             if (looksLikeJson(text)) {
                 parseJsonBlockIntoResults(text, out, 0);
             } else {
                 // "Title: ..." 라인 포맷 등 레거시 텍스트 포맷 처리(옵션)
                 parseLegacyBlock(text, out);
             }
             return;
         }

private boolean looksLikeJson(String s) {
String t = s.trim();
return (t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"));
}

private void parseJsonBlockIntoResults(String json, ArrayNode out) {
try {
JsonNode node = objectMapper.readTree(json);
if (node.isArray()) {
for (JsonNode obj : node) {
addObjToResults(obj, out);
}
} else if (node.isObject()) {
addObjToResults(node, out);
} else if (node.isTextual() && looksLikeJson(node.asText())) {
// 이중 문자열화된 JSON 방어
parseJsonBlockIntoResults(node.asText(), out);
}
} catch (Exception e) {
log.warn("Brave MCP JSON 파싱 실패: {}", truncate(json, 400), e);
}
}

private void addObjToResults(JsonNode obj, ArrayNode out) {
String url = asStr(obj.get("url"));
String title = asStr(obj.get("title"));
String description = asStr(firstNonNull(
obj.get("description"),
obj.get("snippet"),
obj.get("summary"),
obj.get("content")
));

// 최소 하나는 있어야 추가
if ((url != null && !url.isBlank()) ||
(title != null && !title.isBlank()) ||
(description != null && !description.isBlank())) {
ObjectNode one = objectMapper.createObjectNode();
if (url != null) {
one.put("url", url);
}
if (title != null) {
one.put("title", title);
}
if (description != null) {
one.put("description", description);
}
out.add(one);
}
}

private JsonNode firstNonNull(JsonNode... nodes) {
for (JsonNode n : nodes) {
if (n != null && !n.isNull()) {
return n;
}
}
return null;
}

private String asStr(JsonNode n) {
return (n == null || n.isNull()) ? null : n.asText(null);
}

private void addFromTitleUrlBlock(String text, ArrayNode out) {
String[] lines = text.split("\\r?\\n");
String title = null;
String url = null;
StringBuilder body = new StringBuilder();

for (String line : lines) {
if (line.startsWith("Title:")) {
// 이전 블럭 flush
flushOne(out, title, url, body);
title = line.replaceFirst("Title:", "").trim();
url = null;
body.setLength(0);
} else if (line.startsWith("URL:")) {
url = line.replaceFirst("URL:", "").trim();
} else {
body.append(line).append('\n');
}
}
flushOne(out, title, url, body);
}

throw new IllegalStateException("Brave MCP 서버에서 brave_web_search 툴을 찾을 수 없습니다.");
private void flushOne(ArrayNode out, String title, String url, StringBuilder body) {
if (title != null && url != null && body.length() > 0) {
ObjectNode one = objectMapper.createObjectNode();
one.put("title", title);
one.put("url", url);
one.put("description", body.toString().trim());
out.add(one);
}
}

private String truncate(String s, int max) {
if (s == null || s.length() <= max) {
return s;
}
return s.substring(0, max) + "...";
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,51 +16,71 @@ public class BraveSearchRagService {
public List<Document> toDocuments(Optional<JsonNode> resultsNodeOpt) {
List<Document> documents = new ArrayList<>();

resultsNodeOpt.ifPresent(resultsNode -> {
resultsNode.path("results").forEach(result -> {
String text = result.path("text").asText("");
resultsNodeOpt.ifPresent(root -> {
JsonNode results = root.path("results");
if (!results.isArray()) {
return;
}

for (JsonNode r : results) {
// 1) 구조화된 JSON 우선 처리
String url = r.path("url").asText(null);
String title = r.path("title").asText(null);
String description = r.path("description").asText(null);

if ((url != null && !url.isBlank()) ||
(title != null && !title.isBlank()) ||
(description != null && !description.isBlank())) {
String docTitle = (title != null && !title.isBlank())
? title : (url != null ? url : "Web result");
String body = (description != null) ? description : "";
documents.add(new Document(
docTitle,
body,
Map.of("title", docTitle, "url", url == null ? "" : url)
));
continue;
}

// 2) Fallback: "Title:/URL:" 텍스트 포맷
String text = r.path("text").asText("");
if (text.isBlank()) {
return;
continue;
}

// 여러 문서가 한 개의 텍스트에 포함되어 있으므로 줄 단위로 분리
String[] lines = text.split("\\n");

String title = null;
String url = null;
String curTitle = null;
String curUrl = null;
StringBuilder contentBuilder = new StringBuilder();

for (String line : lines) {
if (line.startsWith("Title:")) {
if (title != null && url != null && contentBuilder.length() > 0) {
// 이전 문서를 저장
if (curTitle != null && curUrl != null && contentBuilder.length() > 0) {
documents.add(new Document(
title,
curTitle,
contentBuilder.toString().trim(),
Map.of("title", title, "url", url)
Map.of("title", curTitle, "url", curUrl)
));
contentBuilder.setLength(0);
}
title = line.replaceFirst("Title:", "").trim();
curTitle = line.replaceFirst("Title:", "").trim();
} else if (line.startsWith("URL:")) {
url = line.replaceFirst("URL:", "").trim();
curUrl = line.replaceFirst("URL:", "").trim();
} else {
contentBuilder.append(line).append("\n");
}
}

// 마지막 문서 저장
if (title != null && url != null && contentBuilder.length() > 0) {
if (curTitle != null && curUrl != null && contentBuilder.length() > 0) {
documents.add(new Document(
title,
curTitle,
contentBuilder.toString().trim(),
Map.of("title", title, "url", url)
Map.of("title", curTitle, "url", curUrl)
));
}
});
}
});

return documents;
}

}