Skip to content
Merged
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
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 @@ -18,11 +22,38 @@
public class BraveSearchMcpService {

private static final String BRAVE_WEB_TOOL = "brave_web_search";
private static final int MAX_JSON_RECURSION_DEPTH = 3;

private final List<McpSyncClient> mcpClients;

private final ObjectMapper objectMapper;

private static boolean looksLikeJson(String s) {
if (s == null) {
return false;
}
String t = s.trim();
return (!t.isEmpty()) && (t.charAt(0) == '{' || t.charAt(0) == '[');
}

private static String truncate(String s, int max) {
if (s == null) {
return null;
}
return s.length() > max ? s.substring(0, max) + "…" : s;
}

private static String getTrimmed(JsonNode obj, String field) {
if (obj == null) {
return null;
}
JsonNode n = obj.get(field);
if (n == null || n.isNull()) {
return null;
}
String v = n.asText();
return v == null ? null : v.trim();
}

public JsonNode search(String query, int count, int offset) {
McpSyncClient braveClient = resolveBraveClient();

Expand All @@ -33,31 +64,210 @@ 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);

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.

ArrayNode normalized = objectMapper.createArrayNode();
normalizeContent(raw, normalized);

if (content != null && content.isArray()) {
var root = objectMapper.createObjectNode();
root.set("results", content);
return root;
}
ObjectNode root = objectMapper.createObjectNode();
root.set("results", normalized);

return content != null ? content : objectMapper.createObjectNode();
log.info("Brave 검색 결과 정규화 완료: {}건", normalized.size());
return root;
}

/**
* MCP 클라이언트들 중 brave_web_search 툴을 가진 클라이언트 선택. 초기화되지 않은 경우 1회 initialize() 후 재시도.
*/
private McpSyncClient resolveBraveClient() {
for (McpSyncClient client : mcpClients) {
ListToolsResult tools = client.listTools();
if (tools != null && tools.tools() != null) {
boolean found = tools.tools().stream()
.anyMatch(tool -> BRAVE_WEB_TOOL.equalsIgnoreCase(tool.name()));
if (found) {
try {
ListToolsResult tools = client.listTools();
if (hasBraveTool(tools)) {
return client;
}
} catch (McpError e) {
String msg = e.getMessage() == null ? "" : e.getMessage().toLowerCase(Locale.ROOT);
boolean likelyUninitialized = msg.contains("not initialized")
|| msg.contains("uninitialized")
|| (msg.contains("initialize") && msg.contains("required"));
if (likelyUninitialized) {
// 1회만 초기화 재시도
try {
log.warn("MCP 클라이언트 미초기화 감지 → initialize() 재시도");
client.initialize();
ListToolsResult tools = client.listTools();
if (hasBraveTool(tools)) {
return client;
}
} catch (Exception initError) {
log.error("MCP 클라이언트 초기화 실패: {}", initError.getMessage());
}
} else {
log.debug("listTools() 예외: {}", e.getMessage());
}
} catch (Exception e) {
log.debug("listTools() 일반 예외: {}", e.getMessage());
}
}

throw new IllegalStateException("Brave MCP 서버에서 brave_web_search 툴을 찾을 수 없습니다.");
}
}

private boolean hasBraveTool(ListToolsResult tools) {
return tools != null && tools.tools() != null &&
tools.tools().stream().anyMatch(t -> BRAVE_WEB_TOOL.equalsIgnoreCase(t.name()));
}

/**
* raw JSON(any shape) → results[{url,title,description}]
*/
private void normalizeContent(JsonNode raw, ArrayNode out) {
if (raw == null || raw.isNull()) {
return;
}

if (raw.isArray()) {
for (JsonNode n : raw) {
normalizeOne(n, out);
}
} else {
normalizeOne(raw, out);
}
}

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;
}

// 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;
}

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;
         }

// 순수 문자열이지만 안에 JSON이 들어 있는 경우
if (node.isTextual() && looksLikeJson(node.asText())) {
parseJsonBlockIntoResults(node.asText(), out, 0);
}
}

private void parseJsonBlockIntoResults(String json, ArrayNode out, int depth) {
if (depth > MAX_JSON_RECURSION_DEPTH) {
log.warn("JSON 파싱 재귀 깊이 초과: {}", truncate(json, 100));
return;
}
try {
JsonNode parsed = objectMapper.readTree(json);

if (parsed.isArray()) {
for (JsonNode n : parsed) {
if (n.isObject()) {
addObjectToResults((ObjectNode) n, out);
} else if (n.isTextual() && looksLikeJson(n.asText())) {
parseJsonBlockIntoResults(n.asText(), out, depth + 1);
}
}
} else if (parsed.isObject()) {
ObjectNode obj = (ObjectNode) parsed;
// 루트에 results 배열이 있는 형태 처리
if (obj.has("results") && obj.get("results").isArray()) {
for (JsonNode n : obj.get("results")) {
if (n.isObject()) {
addObjectToResults((ObjectNode) n, out);
} else if (n.isTextual() && looksLikeJson(n.asText())) {
parseJsonBlockIntoResults(n.asText(), out, depth + 1);
}
}
return;
}
if (obj.has("text") && obj.get("text").isTextual() && looksLikeJson(
obj.get("text").asText())) {
// {text:"{...}"} 같은 한 번 더 감싼 케이스
parseJsonBlockIntoResults(obj.get("text").asText(), out, depth + 1);
} else {
addObjectToResults(obj, out);
}
} else if (parsed.isTextual() && looksLikeJson(parsed.asText())) {
parseJsonBlockIntoResults(parsed.asText(), out, depth + 1);
}
} catch (Exception e) {
log.warn("Brave MCP JSON 파싱 실패: {}\n원인: {}", truncate(json, 400), e.getMessage());
}
}

private void addObjectToResults(ObjectNode obj, ArrayNode out) {
String url = getTrimmed(obj, "url");
String title = getTrimmed(obj, "title");
String desc = getTrimmed(obj, "description");

// 세 필드 중 하나라도 있으면 결과로 채택
if ((url != null && !url.isBlank()) ||
(title != null && !title.isBlank()) ||
(desc != null && !desc.isBlank())) {

ObjectNode one = objectMapper.createObjectNode();
if (url != null) {
one.put("url", url);
}
if (title != null) {
one.put("title", title);
}
if (desc != null) {
one.put("description", desc);
}
out.add(one);
}
}

// 레거시 "Title:..., URL:..., 본문..." 형태의 텍스트 파서(있으면 도움, 없어도 무방)
private void parseLegacyBlock(String text, ArrayNode out) {
if (text == null || text.isBlank()) {
return;
}

String[] lines = text.split("\\r?\\n");
String title = null, url = null;
StringBuilder body = new StringBuilder();

for (String line : lines) {
String trimmed = line.trim();
if (trimmed.regionMatches(true, 0, "Title:", 0, 6)) {
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);
body.setLength(0);
}
title = trimmed.substring(6).trim();
} else if (trimmed.regionMatches(true, 0, "URL:", 0, 4)) {
url = trimmed.substring(4).trim();
Comment on lines +246 to +259
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

레거시 파서의 중간 flush 조건에서 본문(body) 유무를 요구하면 유효 결과가 누락됩니다

Title/URL만 있는 케이스도 결과로 인정해야 합니다. 중간 flush 시 body.length() > 0 조건을 제거하세요.

-            if (trimmed.regionMatches(true, 0, "Title:", 0, 6)) {
-                if (title != null && url != null && body.length() > 0) {
+            if (trimmed.regionMatches(true, 0, "Title:", 0, 6)) {
+                if (title != null && url != null) {
                     ObjectNode one = objectMapper.createObjectNode();
                     one.put("title", title);
                     one.put("url", url);
                     one.put("description", body.toString().trim());
                     out.add(one);
                     body.setLength(0);
                 }
                 title = trimmed.substring(6).trim();
📝 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
for (String line : lines) {
String trimmed = line.trim();
if (trimmed.regionMatches(true, 0, "Title:", 0, 6)) {
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);
body.setLength(0);
}
title = trimmed.substring(6).trim();
} else if (trimmed.regionMatches(true, 0, "URL:", 0, 4)) {
url = trimmed.substring(4).trim();
for (String line : lines) {
String trimmed = line.trim();
if (trimmed.regionMatches(true, 0, "Title:", 0, 6)) {
if (title != null && url != null) {
ObjectNode one = objectMapper.createObjectNode();
one.put("title", title);
one.put("url", url);
one.put("description", body.toString().trim());
out.add(one);
body.setLength(0);
}
title = trimmed.substring(6).trim();
} else if (trimmed.regionMatches(true, 0, "URL:", 0, 4)) {
url = trimmed.substring(4).trim();
}
// ...
}
🤖 Prompt for AI Agents
In
cs25-service/src/main/java/com/example/cs25service/domain/ai/service/BraveSearchMcpService.java
around lines 246 to 259, the intermediate "flush" condition currently requires
body.length() > 0 which causes entries that have only Title and URL to be
dropped; remove the body.length() > 0 check so the block flushes whenever title
and url are present, and keep the existing behavior of creating the ObjectNode,
putting title/url/description (use body.toString().trim() which may be empty),
adding it to out, and resetting body with body.setLength(0).

} else {
body.append(line).append('\n');
}
}

if (title != null && url != null) {
ObjectNode one = objectMapper.createObjectNode();
one.put("title", title);
one.put("url", url);
one.put("description", body.toString().trim());
out.add(one);
}
}
}
Loading