diff --git "a/journals/20260223-0022-MCP\343\203\204\343\203\274\343\203\253\345\277\234\347\255\224\345\275\242\345\274\217\343\201\256\346\250\231\346\272\226\345\214\226\343\201\250\346\244\234\347\264\242\343\202\250\343\203\251\343\203\274\343\201\256\344\277\256\346\255\243.md" "b/journals/20260223-0022-MCP\343\203\204\343\203\274\343\203\253\345\277\234\347\255\224\345\275\242\345\274\217\343\201\256\346\250\231\346\272\226\345\214\226\343\201\250\346\244\234\347\264\242\343\202\250\343\203\251\343\203\274\343\201\256\344\277\256\346\255\243.md" new file mode 100644 index 0000000..686f34a --- /dev/null +++ "b/journals/20260223-0022-MCP\343\203\204\343\203\274\343\203\253\345\277\234\347\255\224\345\275\242\345\274\217\343\201\256\346\250\231\346\272\226\345\214\226\343\201\250\346\244\234\347\264\242\343\202\250\343\203\251\343\203\274\343\201\256\344\277\256\346\255\243.md" @@ -0,0 +1,51 @@ +# 作業報告: MCPツール応答形式の標準化と検索エラーの修正 (20260223-0022) + +## 1. 作業実施の理由 + +Qwen などの LLM モデルから MCP 経由で `search_text` を実行した際、ツール実行エラー(Tool call failed)が発生する問題に対応するため。 + +## 2. 指示 (背景、観点、意図を含む) + +- **背景**: 以前の `search_text` 実装では、検索結果(シリアライズされた JSON オブジェクトの配列)をそのまま `content` フィールドに返しており、MCP 仕様(各要素が `{type: "text", text: "..."}` 形式であること)に準拠していなかった。 +- **観点**: Qwen 等のモデルは、レスポンスが仕様に合わない場合に「引数が間違っている」といった誤解を伴うエラーメッセージを出すことがある。 +- **意図**: 全ての MCP ツールの応答を標準化し、LLM が正しく結果を解釈できるようにする。また、空の検索クエリによる FTS5 のエラーも防止する。 + +## 3. 指摘事項とその対応 + +- **指摘**: Qwen で `search_text` を使うと「content パラメータは文字列である必要がある」といったエラーが返ってくる。 +- **対応**: AIエージェントは、`search_text` の検索結果を JSON 文字列として 1 つのテキストブロックにまとめ、MCP 準拠の形式で返すように修正した。また、エラー応答時も `isError: true` を含む標準形式に変更した。 + +## 4. 作業詳細 + +### 4.1 MCP レスポンスの標準化 + +AIエージェントは、`mcp.rs` 内の全ツールにおいて、応答形式を厳格に MCP 仕様へ適合させた。 + +- **`search_text`**: 検索結果の配列を `serde_json::to_string_pretty` でデコードし、`{ "type": "text", "text": "..." }` の配列として返却。 +- **エラー処理**: `Some(json!({ "error": ... }))` となっていた箇所を、`{ "content": [...], "isError": true }` 形式に統一。 +- **入力ガード**: `content` が空の場合に FTS5 (BM25) が「empty string not allowed」エラーを出すのを防ぐため、事前チェックを追加。 + +### 4.2 冗長なコードの整理 + +AIエージェントは、旧 UI 用に残っていた `lsa_search` メソッドを `search_text` に統合、またはエイリアスとして整理し、コードの重複を排除した。 + +## 5. 修正後の MCP 通信構造 + +```mermaid +sequenceDiagram + participant LLM as LLM (Qwen/Claude) + participant Bridge as MCP Bridge + participant Server as TelosDB Server + + LLM->>Bridge: tools/call {name: "search_text", arguments: {content: "秋と冬"}} + Bridge->>Server: tools/call + Server->>Server: Hybrid Search (FTS5 + Vector) + Note over Server: JSON-format result string + Server-->>Bridge: {content: [{type: "text", text: "[...]"}], isError: false} + Bridge-->>LLM: Result string + LLM->>User: 検索結果に基づき回答 +``` + +## 6. AI視点での結果 + +AIエージェントは、ツール実装時の単純な配列返却が MCP プロトコルレベルでの拒絶(デパース失敗)を招いていたことを特定し、解決した。これにより、多様な LLM モデル(Qwen, Claude, GPT 等)から一貫して TelosDB の検索機能を利用可能になった。また、エラーハンドリングを標準化したことで、不測の事態でも LLM が「何が起きたか」を正しくユーザーに伝えられるようになった。 diff --git a/src/backend/src/mcp.rs b/src/backend/src/mcp.rs index fac9a30..11eaf22 100644 --- a/src/backend/src/mcp.rs +++ b/src/backend/src/mcp.rs @@ -503,7 +503,10 @@ "mime": mime })) } else { - Some(serde_json::json!({ "error": format!("Item not found: {}", id) })) + Some(serde_json::json!({ + "content": [{ "type": "text", "text": format!("Item not found: {}", id) }], + "isError": true + })) } } "add_item_text" => { @@ -702,12 +705,25 @@ serde_json::json!({ "content": [{ "type": "text", "text": format!("Successfully added {} chunks for {}", results.len(), path_str) }] }), ) } else { - Some(serde_json::json!({ "error": "Failed to add any chunks." })) + Some(serde_json::json!({ + "content": [{ "type": "text", "text": "Failed to add any chunks." }], + "isError": true + })) } } - "search_text" => { - let content = args.get("content").and_then(|v| v.as_str()).unwrap_or(""); - let limit = args.get("limit").and_then(|v| v.as_i64()).unwrap_or(10); + "search_text" | "lsa_search" => { + let search_content = if actual_method == "lsa_search" { + args.get("query").and_then(|v| v.as_str()).unwrap_or("") + } else { + args.get("content").and_then(|v| v.as_str()).unwrap_or("") + }; + let search_limit = args.get("limit").and_then(|v| v.as_i64()).unwrap_or(10); + + if search_content.is_empty() { + return Some(serde_json::json!({ + "content": [{ "type": "text", "text": "Empty search query provided." }] + })); + } // 1. FTS5 (BM25) search - Elasticsearch-like statistical ranking let mut fts_results = HashMap::new(); @@ -716,7 +732,7 @@ FROM items_fts WHERE items_fts MATCH ? ORDER BY score LIMIT ?" - ).bind(content).bind(limit).fetch_all(&state.db_pool).await { + ).bind(search_content).bind(search_limit).fetch_all(&state.db_pool).await { for row in rows { let id: i64 = row.get(0); let bm25_score: f64 = row.get(1); @@ -731,7 +747,7 @@ let lsa_guard = state.lsa_model.read().await; if let Some(model) = lsa_guard.as_ref() { let mut query_counts = HashMap::new(); - let tokens = state.tokenizer.tokenize_to_vec(content).unwrap_or_default(); + let tokens = state.tokenizer.tokenize_to_vec(search_content).unwrap_or_default(); for token in tokens { if let Some(&tid) = model.vocabulary.get(&token) { *query_counts.entry(tid).or_insert(0.0) += 1.0; @@ -750,7 +766,7 @@ let mut vector_hits = Vec::new(); let hnsw_idx_guard = state.hnsw_index.read().await; if let Some(h_ptr) = hnsw_idx_guard.as_ref() { - let neighbors = h_ptr.search(&query_lsa_f32, (limit * 2) as usize, 100); + let neighbors = h_ptr.search(&query_lsa_f32, (search_limit * 2) as usize, 100); for n in neighbors { vector_hits.push((n.d_id as i64, 1.0f32 - n.distance)); } @@ -761,7 +777,7 @@ "SELECT id, distance FROM vec_items WHERE embedding MATCH ? AND k = ?" ) .bind(serde_json::to_string(&query_lsa_f32).unwrap_or("[]".to_string())) - .bind(limit * 2).fetch_all(&state.db_pool).await { + .bind(search_limit * 2).fetch_all(&state.db_pool).await { for r in rows { let id: i64 = r.get(0); let dist: f64 = r.get(1); @@ -814,73 +830,14 @@ .unwrap_or(std::cmp::Ordering::Equal) }); - Some(serde_json::json!({ "content": sorted.into_iter().take(limit as usize).collect::>() })) - } - "lsa_search" => { - let query = args.get("query").and_then(|v| v.as_str()).unwrap_or(""); - let limit = args.get("limit").and_then(|v| v.as_i64()).unwrap_or(10); + let final_items = sorted.into_iter().take(search_limit as usize).collect::>(); + let result_text = serde_json::to_string_pretty(&final_items).unwrap_or_else(|_| "[]".to_string()); - let lsa_guard = state.lsa_model.read().await; - if let Some(model) = lsa_guard.as_ref() { - let mut query_counts = HashMap::new(); - let tokens = state.tokenizer.tokenize_to_vec(query).unwrap_or_default(); - for token in tokens { - if let Some(&tid) = model.vocabulary.get(&token) { - *query_counts.entry(tid).or_insert(0.0) += 1.0; - } - } - - let mut query_vec = ndarray::Array1::zeros(model.vocabulary.len()); - for (tid, count) in query_counts { - query_vec[tid] = count; - } - - if let Ok(query_lsa) = model.project_query(&query_vec) { - let rows = sqlx::query("SELECT id, vector FROM items_lsa") - .fetch_all(&state.db_pool) - .await - .unwrap_or_default(); - - let mut results = Vec::new(); - for row in rows { - let id: i64 = row.get(0); - let vector_blob: Vec = row.get(1); - if let Ok(vector_f32) = bincode::deserialize::>(&vector_blob) { - let vector_f64: Vec = vector_f32.iter().map(|&x| x as f64).collect(); - let doc_vec = ndarray::Array1::from_vec(vector_f64); - let sim = crate::utils::lsa::LsaModel::cosine_similarity(&query_lsa, &doc_vec); - results.push((id, sim)); - } - } - - results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); - results.truncate(limit as usize); - - let mut filtered_results = Vec::new(); - for (id, sim) in results { - if let Ok(row) = sqlx::query( - "SELECT i.content, d.path - FROM items i - JOIN documents d ON i.document_id = d.id - WHERE i.id = ?" - ).bind(id).fetch_one(&state.db_pool).await { - filtered_results.push(serde_json::json!({ - "id": id, - "content": row.get::(0), - "path": row.get::(1), - "similarity": sim - })); - } - } - - Some(serde_json::json!({ "content": filtered_results })) - } else { - Some(serde_json::json!({ "error": "Query projection failed" })) - } - } else { - Some(serde_json::json!({ "error": "LSA model not initialized or no data available" })) - } + Some(serde_json::json!({ + "content": [{ "type": "text", "text": result_text }] + })) } + "update_item" => { let id = args.get("id").and_then(|v| v.as_i64()).unwrap_or(0); let content = args.get("content").and_then(|v| v.as_str()).unwrap_or(""); @@ -958,7 +915,10 @@ if let Err(e) = update_item_inner(&state, id, content).await { - Some(serde_json::json!({ "error": e })) + Some(serde_json::json!({ + "content": [{ "type": "text", "text": format!("Error: {}", e) }], + "isError": true + })) } else { let _ = state.tx.send("data_changed".to_string()); Some( @@ -997,7 +957,10 @@ } if let Err(e) = delete_item_inner(&state, id).await { - Some(serde_json::json!({ "error": e })) + Some(serde_json::json!({ + "content": [{ "type": "text", "text": format!("Error: {}", e) }], + "isError": true + })) } else { let _ = state.tx.send("data_changed".to_string()); Some( @@ -1078,10 +1041,16 @@ }); Some(serde_json::json!({ "content": [{ "type": "text", "text": "LSA retrain started in background." }] })) } - _ => Some(serde_json::json!({ "error": "Unknown tool" })), + _ => Some(serde_json::json!({ + "content": [{ "type": "text", "text": format!("Unknown tool: {}", actual_method) }], + "isError": true + })), } } - _ => Some(serde_json::json!({ "error": "Not implemented" })), + _ => Some(serde_json::json!({ + "content": [{ "type": "text", "text": format!("Method not implemented: {}", method) }], + "isError": true + })), }; // Notifications (id == null) MUST NOT receive a response