From 2c897871cfced026ed77231d8743589d0ac8f5f6 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 17:27:35 +0900 Subject: [PATCH] Add MCP response guidance hints (#1641, #1812) --- changelog.d/unreleased/1641.added.md | 17 ++++ changelog.d/unreleased/1812.added.md | 17 ++++ src/CodeIndex/Mcp/McpToolDefinitions.cs | 6 +- src/CodeIndex/Mcp/McpToolHandlers.cs | 103 ++++++++++++++++++++++++ tests/CodeIndex.Tests/McpServerTests.cs | 68 ++++++++++++++++ 5 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 changelog.d/unreleased/1641.added.md create mode 100644 changelog.d/unreleased/1812.added.md diff --git a/changelog.d/unreleased/1641.added.md b/changelog.d/unreleased/1641.added.md new file mode 100644 index 0000000000..ea1233dc49 --- /dev/null +++ b/changelog.d/unreleased/1641.added.md @@ -0,0 +1,17 @@ +--- +category: added +issues: + - 1641 +affected: + - src/CodeIndex/Mcp/McpToolHandlers.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP search, references, and excerpt responses now suggest the next read step (#1641)** - non-empty responses include `next_step_suggestion` so agents can follow search/reference hits into `excerpt`, or move from an excerpt to `outline`, without guessing the next tool call. + +## 日本語 + +- **MCP の search / references / excerpt レスポンスが次の読み取り手順を提案するようになりました (#1641)** - 非空レスポンスに `next_step_suggestion` を含め、エージェントが search / reference のヒットから `excerpt` へ、また excerpt から `outline` へ迷わず進めるようにしました。 diff --git a/changelog.d/unreleased/1812.added.md b/changelog.d/unreleased/1812.added.md new file mode 100644 index 0000000000..22ecccf789 --- /dev/null +++ b/changelog.d/unreleased/1812.added.md @@ -0,0 +1,17 @@ +--- +category: added +issues: + - 1812 +affected: + - src/CodeIndex/Mcp/McpToolHandlers.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP empty-result responses now include recovery hints (#1812)** - empty read-tool responses add `recovery_hint` with a reason and suggested follow-up, covering search, definition, references, callers, callees, excerpt, impact analysis, unused symbols, and symbol hotspots. + +## 日本語 + +- **MCP の空結果レスポンスに復旧ヒントを追加しました (#1812)** - search、definition、references、callers、callees、excerpt、impact analysis、unused symbols、symbol hotspots の空レスポンスに、理由と次に試す操作を示す `recovery_hint` を追加しました。 diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 698ea0473b..040c6ba871 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -20,7 +20,7 @@ private JsonNode HandleToolsList(JsonNode? id) { CreateToolDefinition( "search", - "Full-text search across indexed code chunks using FTS5. Returns compact match-centered snippets with line metadata. The literal-safe path quotes each whitespace-separated token as an FTS5 phrase and combines multiple tokens with implicit AND semantics (`foo bar` requires both terms). For CJK that means `search 計算` no longer also matches `計算する`/`計算機`, because unicode61 keeps adjacent CJK codepoints in one token. Two opt-ins enable FTS5 prefix expansion: (1) trailing `*` on a single token in the `query` string (`search 計算*` to match `計算する`); (2) the `prefix` flag, which promotes every token in the query to a prefix phrase. Use `exactSubstring` for case-sensitive exact-substring matching that bypasses FTS5 entirely; `exact` is the backward-compatible alias documented in USER_GUIDE.md's flag compatibility table. Non-CJK tokens follow the same rule — ASCII identifiers also no longer auto-prefix, so use `--prefix` or trailing `*` to widen. Emoji-mixed tokens cannot be distinguished from their plain ASCII counterpart at the FTS layer (unicode61 drops the emoji on both index and query side — `foo🎉` is FTS-equivalent to `foo`), and pure emoji substring search is 0-result for the same reason; use `exactSubstring` when emoji identity matters. Examples: `search {\"query\":\"handleRequest\",\"lang\":\"csharp\"}`; `search {\"query\":\"Authenticate\",\"lang\":\"csharp\",\"path\":\"src/Auth\",\"prefix\":true}`. / FTS5を使ったコードチャンクの全文検索。一致中心の軽量スニペットと行メタデータを返す。literal-safe 経路は空白区切りの各トークンを FTS5 phrase として引用し、複数 token は implicit AND として結合する(`foo bar` は両方の term を要求する)。CJK の場合、unicode61 は隣接 CJK コードポイントを一語として扱うため、`search 計算` は `計算する`/`計算機` にはマッチしない。FTS5 prefix への昇格は 2 通りでオプトイン: (1) `query` 文字列内のトークン末尾に `*` を付ける(`search 計算*` で `計算する` にマッチ)。(2) `prefix` フラグで、クエリの全トークンを prefix phrase に昇格させる。`exactSubstring` を使うと FTS5 を経由せず大小文字区別の厳密部分文字列マッチになり、`exact` は USER_GUIDE.md の flag compatibility table に記載された後方互換 alias。CJK 以外(ASCII 識別子等)も同じルールで、自動 prefix は行わないため、広げたい場合は `--prefix` か末尾 `*` を使う。絵文字混在トークンは、unicode61 が indexing とクエリの両側で絵文字を削ぐため FTS 層で素の ASCII トークンと区別できず(`foo🎉` は FTS 上 `foo` と等価)、絵文字単独の部分一致も同じ理由で 0 件になる。絵文字の同一性が必要な場合は `exactSubstring` を使う。例: `search {\"query\":\"handleRequest\",\"lang\":\"csharp\"}`; `search {\"query\":\"Authenticate\",\"lang\":\"csharp\",\"path\":\"src/Auth\",\"prefix\":true}`。", + "Full-text search across indexed code chunks using FTS5. Returns compact match-centered snippets with line metadata. Non-empty responses include `next_step_suggestion` for the obvious follow-up read, and empty responses include `recovery_hint`. The literal-safe path quotes each whitespace-separated token as an FTS5 phrase and combines multiple tokens with implicit AND semantics (`foo bar` requires both terms). For CJK that means `search 計算` no longer also matches `計算する`/`計算機`, because unicode61 keeps adjacent CJK codepoints in one token. Two opt-ins enable FTS5 prefix expansion: (1) trailing `*` on a single token in the `query` string (`search 計算*` to match `計算する`); (2) the `prefix` flag, which promotes every token in the query to a prefix phrase. Use `exactSubstring` for case-sensitive exact-substring matching that bypasses FTS5 entirely; `exact` is the backward-compatible alias documented in USER_GUIDE.md's flag compatibility table. Non-CJK tokens follow the same rule — ASCII identifiers also no longer auto-prefix, so use `--prefix` or trailing `*` to widen. Emoji-mixed tokens cannot be distinguished from their plain ASCII counterpart at the FTS layer (unicode61 drops the emoji on both index and query side — `foo🎉` is FTS-equivalent to `foo`), and pure emoji substring search is 0-result for the same reason; use `exactSubstring` when emoji identity matters. Examples: `search {\"query\":\"handleRequest\",\"lang\":\"csharp\"}`; `search {\"query\":\"Authenticate\",\"lang\":\"csharp\",\"path\":\"src/Auth\",\"prefix\":true}`. / FTS5を使ったコードチャンクの全文検索。一致中心の軽量スニペットと行メタデータを返す。非空レスポンスには自然な次の読み取りを示す `next_step_suggestion`、空レスポンスには `recovery_hint` を含める。literal-safe 経路は空白区切りの各トークンを FTS5 phrase として引用し、複数 token は implicit AND として結合する(`foo bar` は両方の term を要求する)。CJK の場合、unicode61 は隣接 CJK コードポイントを一語として扱うため、`search 計算` は `計算する`/`計算機` にはマッチしない。FTS5 prefix への昇格は 2 通りでオプトイン: (1) `query` 文字列内のトークン末尾に `*` を付ける(`search 計算*` で `計算する` にマッチ)。(2) `prefix` フラグで、クエリの全トークンを prefix phrase に昇格させる。`exactSubstring` を使うと FTS5 を経由せず大小文字区別の厳密部分文字列マッチになり、`exact` は USER_GUIDE.md の flag compatibility table に記載された後方互換 alias。CJK 以外(ASCII 識別子等)も同じルールで、自動 prefix は行わないため、広げたい場合は `--prefix` か末尾 `*` を使う。絵文字混在トークンは、unicode61 が indexing とクエリの両側で絵文字を削ぐため FTS 層で素の ASCII トークンと区別できず(`foo🎉` は FTS 上 `foo` と等価)、絵文字単独の部分一致も同じ理由で 0 件になる。絵文字の同一性が必要な場合は `exactSubstring` を使う。例: `search {\"query\":\"handleRequest\",\"lang\":\"csharp\"}`; `search {\"query\":\"Authenticate\",\"lang\":\"csharp\",\"path\":\"src/Auth\",\"prefix\":true}`。", new JsonObject { ["type"] = "object", @@ -73,7 +73,7 @@ private JsonNode HandleToolsList(JsonNode? id) ReadOnlyAnnotations()), CreateToolDefinition( "references", - "Search indexed symbol references such as call sites. Pass `lsp_compatible:true` to add `uri` and LSP `range` fields to each result. For exact matches, use `exactName`; `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. When `kind` is omitted, all indexed reference kinds including metadata uses (`attribute` / `annotation`) and compile-time type-position references (`type_reference`) stay visible, and identical constructor `call` + `instantiate` rows at one physical site are collapsed. Pass `kind: \"type_reference\"` to enumerate declaration types, generic constraints, `is`/`as`/`instanceof`, and XML-doc `cref` targets. Examples: `references {\"query\":\"Run\"}`; `references {\"query\":\"Service\",\"kind\":\"type_reference\",\"lang\":\"csharp\"}`. / 呼び出し箇所などのインデックス済みシンボル参照を検索。`lsp_compatible:true` で各結果に `uri` と LSP `range` を追加する。完全一致には `exactName` を使う。`exact` は USER_GUIDE.md の flag compatibility table に記載された legacy alias。`kind` 未指定時は metadata (`attribute` / `annotation`) と compile-time な型位置参照 (`type_reference`) も含む全 reference kind を表示したうえで、同じ物理位置にある constructor の `call` + `instantiate` 重複行を集約する。`kind: \"type_reference\"` を指定すると、宣言型・generic 制約・`is`/`as`/`instanceof`・XML-doc `cref` 対象を列挙できる。例: `references {\"query\":\"Run\"}`; `references {\"query\":\"Service\",\"kind\":\"type_reference\",\"lang\":\"csharp\"}`。", + "Search indexed symbol references such as call sites. Non-empty responses include `next_step_suggestion` for reading the top hit context; empty responses include `recovery_hint`. Pass `lsp_compatible:true` to add `uri` and LSP `range` fields to each result. For exact matches, use `exactName`; `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. When `kind` is omitted, all indexed reference kinds including metadata uses (`attribute` / `annotation`) and compile-time type-position references (`type_reference`) stay visible, and identical constructor `call` + `instantiate` rows at one physical site are collapsed. Pass `kind: \"type_reference\"` to enumerate declaration types, generic constraints, `is`/`as`/`instanceof`, and XML-doc `cref` targets. Examples: `references {\"query\":\"Run\"}`; `references {\"query\":\"Service\",\"kind\":\"type_reference\",\"lang\":\"csharp\"}`. / 呼び出し箇所などのインデックス済みシンボル参照を検索。非空レスポンスには先頭ヒットの文脈を読む `next_step_suggestion`、空レスポンスには `recovery_hint` を含める。`lsp_compatible:true` で各結果に `uri` と LSP `range` を追加する。完全一致には `exactName` を使う。`exact` は USER_GUIDE.md の flag compatibility table に記載された legacy alias。`kind` 未指定時は metadata (`attribute` / `annotation`) と compile-time な型位置参照 (`type_reference`) も含む全 reference kind を表示したうえで、同じ物理位置にある constructor の `call` + `instantiate` 重複行を集約する。`kind: \"type_reference\"` を指定すると、宣言型・generic 制約・`is`/`as`/`instanceof`・XML-doc `cref` 対象を列挙できる。例: `references {\"query\":\"Run\"}`; `references {\"query\":\"Service\",\"kind\":\"type_reference\",\"lang\":\"csharp\"}`。", new JsonObject { ["type"] = "object", @@ -191,7 +191,7 @@ private JsonNode HandleToolsList(JsonNode? id) ReadOnlyAnnotations()), CreateToolDefinition( "excerpt", - "Reconstruct a file excerpt from indexed chunks for a given line range. / 指定行範囲について、インデックス済みチャンクからファイル抜粋を再構成。", + "Reconstruct a file excerpt from indexed chunks for a given line range. Successful responses include `next_step_suggestion` for the file outline; empty responses include `recovery_hint`. / 指定行範囲について、インデックス済みチャンクからファイル抜粋を再構成。成功レスポンスにはファイル outline への `next_step_suggestion`、空レスポンスには `recovery_hint` を含める。", new JsonObject { ["type"] = "object", diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index a7f42aa0c0..4155a9b1aa 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -163,6 +163,59 @@ private static void AddExactZeroHint(JsonObject payload, ExactZeroHintResult? ex payload["exact_zero_hint"]!["relaxed_count"] = exactZeroHint.RelaxedCount.Value; } + private static void AddRecoveryHint(JsonObject payload, string reason, string suggestedAction, string? tool = null, JsonObject? args = null) + { + var hint = new JsonObject + { + ["reason"] = reason, + ["suggested_action"] = suggestedAction, + }; + if (tool != null) + hint["tool"] = tool; + if (args != null) + hint["args"] = args; + payload["recovery_hint"] = hint; + } + + private static void AddSymbolRecoveryHint(JsonObject payload, string query, string toolName, string? lang, string? kind, JsonNode? path) + { + var args = new JsonObject + { + ["query"] = query, + ["limit"] = 5, + }; + if (lang != null) + args["lang"] = lang; + if (kind != null) + args["kind"] = kind; + if (path != null) + args["path"] = path.DeepClone(); + + AddRecoveryHint( + payload, + "no_results", + $"{toolName} returned no rows; check whether the symbol is indexed, whether filters are too narrow, or whether a broader symbol lookup finds a nearby name.", + "symbols", + args); + } + + private static void AddNextStepSuggestion(JsonObject payload, string tool, JsonObject args) + { + payload["next_step_suggestion"] = new JsonObject + { + ["tool"] = tool, + ["args"] = args, + }; + } + + private static JsonObject BuildExcerptArgs(string path, int startLine, int endLine) + => new() + { + ["path"] = path, + ["startLine"] = startLine, + ["endLine"] = endLine, + }; + /// /// Clamp limit to a safe range to prevent resource exhaustion. /// リソース枯渇を防ぐためlimitを安全な範囲にクランプ。 @@ -783,6 +836,12 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) ["results"] = new JsonArray() }; AddResultEnvelope(payload, 0, 0, truncated: false); + AddRecoveryHint( + payload, + "no_results", + "search returned no rows; try removing lang/path filters, using prefix for token-prefix matches, or using exactSubstring for literal punctuation or emoji.", + "search", + new JsonObject { ["query"] = query, ["limit"] = 5 }); AddFreshnessHint(payload, reader); return CreateToolResult(id, "No results found.", payload); } @@ -798,6 +857,11 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) ["results"] = ToJsonArray(SearchSnippetFormatter.ToCompactResults(results, query, snippetLines, exact, maxLineWidth)) }; AddResultEnvelope(structured, results.Count, truncated ? null : results.Count, truncated); + var topResult = results[0]; + AddNextStepSuggestion( + structured, + "excerpt", + BuildExcerptArgs(topResult.Path, topResult.StartLine, topResult.EndLine)); // Include top file paths in summary for quick AI orientation // AIが素早く位置把握できるよう、サマリにトップファイルパスを含める var topPaths = results.Select(r => r.Path).Distinct().Take(3); @@ -970,6 +1034,7 @@ private JsonNode ExecuteDefinition(JsonNode? id, JsonNode? args) if (results.Count == 0) { AddExactZeroHint(payload, exactZeroHint); + AddSymbolRecoveryHint(payload, query, "definition", lang, kind, PathEcho(pathPatterns)); AddFreshnessHint(payload, reader); } return CreateToolResult(id, @@ -1059,8 +1124,17 @@ private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args) if (results.Count == 0) { AddExactZeroHint(payload, exactZeroHint); + AddSymbolRecoveryHint(payload, query, "references", lang, kind, PathEcho(pathPatterns)); AddFreshnessHint(payload, reader); } + else + { + var topReference = results[0]; + AddNextStepSuggestion( + payload, + "excerpt", + BuildExcerptArgs(topReference.Path, topReference.Line, topReference.Line)); + } return CreateToolResult(id, BuildGraphSummary("reference", "references", results.Count, graphSupport.GraphLanguage, graphSupport.GraphSupported, graphSupport.GraphSupportReason), payload); @@ -1147,6 +1221,7 @@ private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) if (results.Count == 0) { AddExactZeroHint(payload, exactZeroHint); + AddSymbolRecoveryHint(payload, query, "callers", lang, kind, PathEcho(pathPatterns)); AddFreshnessHint(payload, reader); } return CreateToolResult(id, @@ -1235,6 +1310,7 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) if (results.Count == 0) { AddExactZeroHint(payload, exactZeroHint); + AddSymbolRecoveryHint(payload, query, "callees", lang, kind, PathEcho(pathPatterns)); AddFreshnessHint(payload, reader); } return CreateToolResult(id, @@ -1777,6 +1853,12 @@ private JsonNode ExecuteExcerpt(JsonNode? id, JsonNode? args) ["path"] = path, ["count"] = 0 }; + AddRecoveryHint( + emptyPayload, + "file_or_range_not_indexed", + "excerpt found no indexed content for the requested range; verify the path with files or outline, then retry with an indexed line range.", + "outline", + new JsonObject { ["path"] = path }); AddFreshnessHint(emptyPayload, reader); return CreateToolResult(id, "No excerpt found.", emptyPayload); } @@ -1788,6 +1870,10 @@ private JsonNode ExecuteExcerpt(JsonNode? id, JsonNode? args) if (focusColumn.HasValue) payload["focusColumn"] = focusColumn.Value; payload["focusLength"] = focusLength; + AddNextStepSuggestion( + payload, + "outline", + new JsonObject { ["path"] = excerpt.Path }); return CreateToolResult(id, "Excerpt returned.", payload); }); } @@ -2518,6 +2604,7 @@ private JsonNode ExecuteImpactAnalysis(JsonNode? id, JsonNode? args) if (count == 0) { + AddSymbolRecoveryHint(payload, query, "impact_analysis", lang, null, PathEcho(pathPatterns)); AddFreshnessHint(payload, reader); var graphReason = ReferenceExtractor.BuildGraphSupportReason(lang, lang != null ? ReferenceExtractor.SupportsLanguage(lang) : null); if (graphReason != null) @@ -2650,7 +2737,15 @@ private JsonNode ExecuteSymbolHotspots(JsonNode? id, JsonNode? args) summary += " Warning: cross-file hotspot family grouping is degraded, so results may be conservative until the next successful reindex."; } if (visibleCount == 0) + { + AddRecoveryHint( + payload, + "no_results", + "symbol_hotspots returned no rows; verify that graph references are indexed and loosen kind/lang/path filters.", + "status", + new JsonObject()); AddFreshnessHint(payload, reader); + } return CreateToolResult(id, summary, payload); }); } @@ -2707,7 +2802,15 @@ private JsonNode ExecuteUnusedSymbols(JsonNode? id, JsonNode? args) summary += " Warning: symbol_references table is missing in this index; zero-result unused output is degraded, not authoritative."; } if (results.Count == 0) + { + AddRecoveryHint( + payload, + "no_results", + "unused_symbols returned no rows; verify graph readiness and loosen kind/lang/path filters before treating this as authoritative.", + "status", + new JsonObject()); AddFreshnessHint(payload, reader); + } return CreateToolResult(id, summary, payload); }); } diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 1cfd82820d..4c894a510e 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -228,6 +228,74 @@ void Target() { } Assert.Contains("Gamma", allNames); } + [Fact] + public void ToolsCall_Search_WithResultsIncludesNextStepSuggestion() + { + var request = JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"query":"public class App","limit":1}}}""")!; + + var response = _server.HandleMessage(request)!; + var structured = response["result"]!["structuredContent"]!; + var suggestion = structured["next_step_suggestion"]!; + + Assert.Equal("excerpt", suggestion["tool"]!.GetValue()); + Assert.Equal("src/app.cs", suggestion["args"]!["path"]!.GetValue()); + Assert.True(suggestion["args"]!["startLine"]!.GetValue() >= 1); + Assert.True(suggestion["args"]!["endLine"]!.GetValue() >= suggestion["args"]!["startLine"]!.GetValue()); + } + + [Fact] + public void ToolsCall_References_WithResultsIncludesNextStepSuggestion() + { + InsertIndexedFile( + "src/reference-hint.cs", + "csharp", + """ + class ReferenceHint { + void Caller() { Target(); } + void Target() { } + } + """); + var request = JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"references","arguments":{"query":"Target","lang":"csharp","exactName":true,"limit":1}}}""")!; + + var response = _server.HandleMessage(request)!; + var structured = response["result"]!["structuredContent"]!; + var suggestion = structured["next_step_suggestion"]!; + + Assert.Equal("excerpt", suggestion["tool"]!.GetValue()); + Assert.Equal("src/reference-hint.cs", suggestion["args"]!["path"]!.GetValue()); + } + + [Fact] + public void ToolsCall_Excerpt_WithResultIncludesNextStepSuggestion() + { + var request = JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"excerpt","arguments":{"path":"src/app.cs","startLine":1,"endLine":1}}}""")!; + + var response = _server.HandleMessage(request)!; + var structured = response["result"]!["structuredContent"]!; + var suggestion = structured["next_step_suggestion"]!; + + Assert.Equal("outline", suggestion["tool"]!.GetValue()); + Assert.Equal("src/app.cs", suggestion["args"]!["path"]!.GetValue()); + } + + [Fact] + public void ToolsCall_Callers_EmptyResultIncludesRecoveryHint() + { + var request = JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"callers","arguments":{"query":"MissingSymbol","lang":"csharp","exactName":true}}}""")!; + + var response = _server.HandleMessage(request)!; + var structured = response["result"]!["structuredContent"]!; + var hint = structured["recovery_hint"]!; + + Assert.Equal("no_results", hint["reason"]!.GetValue()); + Assert.Equal("symbols", hint["tool"]!.GetValue()); + Assert.Equal("MissingSymbol", hint["args"]!["query"]!.GetValue()); + } + // --- Protocol tests / プロトコルテスト --- [Fact]