diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index abed5af659..3e32969add 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -978,6 +978,8 @@ Process exit codes are coarse (`0` success including valid zero-row queries, `1` - **Structured MCP responses** — MCP tool calls return typed JSON in `structuredContent` and keep `content` concise for compatibility. - **MCP `batch_query` response cap** — `batch_query` estimates the UTF-8 JSON size of aggregate slot results and stops appending once the response would exceed `CDIDX_MCP_BATCH_RESPONSE_MAX_BYTES` (default: 1,000,000 bytes, aligned with the JSON-RPC line cap). Truncated responses include `truncated: true`, `truncated_queries`, and byte-limit metadata so clients can split the batch or lower per-slot limits without parsing prose (#1416). - **MCP array argument bounds** — MCP string-array filters such as `path`, `project`, `excludePaths`, and mixed `names` arrays reject invalid entries instead of silently dropping them. Arrays are capped at 100 entries and each entry is capped at 4096 characters; `batch_query` reports these validation failures per slot with `request_index` and `ok: false`. +- **MCP schema lock-down** — Every tool `inputSchema` includes `additionalProperties: false`, and `tools/call` mirrors that contract by rejecting unknown argument names with `-32602` / `invalid_argument` instead of silently defaulting misspelled fields. +- **MCP stability markers and naming** — Every tool advertises `x-stability` (`stable`, `experimental`, or `deprecated`). MCP structured payload keys use snake_case, matching the CLI JSON contract; do not add camelCase aliases for new fields. - **MCP language-support clauses** — Every advertised MCP tool description ends with a `Language support:` clause generated through `McpServer.CreateToolDefinition`. Graph tools enumerate `ReferenceExtractor.GetSupportedLanguages()`, symbol tools enumerate `SymbolExtractor.GetSupportedLanguages()`, and file/content tools point at the detected-language catalog used by `cdidx languages`, so `tools/list` stays aligned with the runtime registries instead of carrying hand-maintained prose. - **MCP tool annotations** — All tools emit `annotations` with `readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint` per the MCP spec, so AI clients can auto-approve safe read-only queries. - **MCP server instructions** — The `initialize` response includes an `instructions` string with tool-selection guidance so AI clients can choose the right tool on first connection. @@ -2550,6 +2552,8 @@ USER_GUIDEの[終了コード](USER_GUIDE.md#終了コード)セクションを - **構造化MCPレスポンス** — MCPツール呼び出しは `structuredContent` に型付きJSONを返し、`content` は互換性のため簡潔に保つ。 - **MCP `batch_query` レスポンス上限** — `batch_query` は集約した slot 結果の UTF-8 JSON サイズを見積もり、`CDIDX_MCP_BATCH_RESPONSE_MAX_BYTES`(既定: JSON-RPC 行上限と揃えた 1,000,000 bytes)を超える場合は追加を止める。切り詰めたレスポンスには `truncated: true`、`truncated_queries`、byte limit メタデータを含めるため、クライアントは prose を parsing せず batch 分割や slot limit 縮小を判断できる (#1416)。 - **MCP 配列引数の上限** — `path` / `project` / `excludePaths` / mixed `names` などの string-array filter は、不正要素を暗黙に落とさず拒否する。配列は 100 件、各要素は 4096 文字を上限とし、`batch_query` では `request_index` と `ok: false` 付きの slot 失敗として報告する。 +- **MCP schema のロックダウン** — すべての tool `inputSchema` は `additionalProperties: false` を含み、`tools/call` も同じ契約として未知の引数名を黙って既定値にせず `-32602` / `invalid_argument` で拒否する。 +- **MCP stability marker と命名** — すべての tool は `x-stability`(`stable`、`experimental`、`deprecated`)を公開する。MCP の構造化 payload key は CLI JSON 契約に合わせて snake_case を使う。新規 field に camelCase alias を追加しないこと。 - **MCP の言語サポート句** — 公開されるすべての MCP ツール説明は、`McpServer.CreateToolDefinition` で生成される `Language support:` 句で終わる。Graph 系ツールは `ReferenceExtractor.GetSupportedLanguages()`、symbol 系ツールは `SymbolExtractor.GetSupportedLanguages()`、file/content 系ツールは `cdidx languages` と同じ検出言語カタログを参照するため、`tools/list` は手書き説明ではなく実行時レジストリと同期する。 - **MCPツールアノテーション** — 全ツールが MCP 仕様に沿った `annotations`(`readOnlyHint`、`destructiveHint`、`idempotentHint`、`openWorldHint`)を返し、AIクライアントが安全な読み取り専用クエリを自動承認できるようにする。 - **MCPサーバー instructions** — `initialize` レスポンスにツール選択ガイダンスの `instructions` 文字列を含め、AIクライアントが初回接続時に適切なツールを選べるようにする。 diff --git a/README.md b/README.md index fdb362a64b..dd5af1fa40 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ file completion. | Search surfaces | CLI-first output for humans and machines; full-text, symbol, reference, caller/callee, dependency, map, inspect, and excerpt commands. | | Ranking and filters | Public/exported symbol matches rank ahead of protected, internal, and private matches. Use `--no-visibility-rank` for legacy order, and `--visibility` / `--exclude-visibility` with `symbols`, `definition`, `unused`, and `hotspots`. Query defaults can be adjusted with `CDIDX_DEFAULT_LIMIT`, `CDIDX_DEFAULT_SNIPPET_LINES`, and `CDIDX_DEFAULT_MAX_LINE_WIDTH`; explicit CLI flags still win. | | Project scoping | `.sln` / `.csproj`-aware --project <name|path> filters for indexing and queries, plus `--solution ` when a workspace has multiple solution files. | -| MCP integration | MCP server support for AI clients such as Claude Code, Cursor, and Windsurf, including tools, indexed-file resources, starter prompts, schema constraints for local argument validation, `mimeType` on text content blocks, logging, a compatibility server-side `notifications/initialized` ready signal on stdio or HTTP `/events` streams, and `Language support:` descriptions sourced from the same registries as `cdidx languages`. | +| MCP integration | MCP server support for AI clients such as Claude Code, Cursor, and Windsurf, including tools, indexed-file resources, starter prompts, schema constraints for local argument validation, `mimeType` on text content blocks, logging, a compatibility server-side `notifications/initialized` ready signal on stdio or HTTP `/events` streams, and `Language support:` descriptions sourced from the same registries as `cdidx languages`. Tool schemas reject unknown arguments with `-32602`, advertise `x-stability`, and use snake_case structured JSON keys to match the CLI JSON contract. | | Freshness | Parallel full-scan extraction with `--parallelism`, incremental refreshes with `--files` and `--commits`, continuous `--watch`, exact `status --check`, and configurable stale thresholds via `--stale-after` / `CDIDX_STALE_AFTER`. | | Storage | Local-first `.cdidx/codeindex.db` storage. Query commands run from nested directories prefer the outermost ancestor `.cdidx/codeindex.db` before falling back to the current directory. `--data-dir `, `CDIDX_DATA_DIR`, or `XDG_DATA_HOME` can move default SQLite storage outside the workspace; explicit `--db ` still wins. | | DB maintenance | New indexes use SQLite incremental auto-vacuum. `cdidx vacuum` reclaims free pages from existing DBs, including a one-time full `VACUUM` conversion for legacy no-autovacuum DBs, and `status --json` reports metrics under `db_pragma_settings`. | diff --git a/changelog.d/unreleased/1696.added.md b/changelog.d/unreleased/1696.added.md new file mode 100644 index 0000000000..adc179d507 --- /dev/null +++ b/changelog.d/unreleased/1696.added.md @@ -0,0 +1,18 @@ +--- +category: added +issues: + - 1696 +affected: + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - README.md + - DEVELOPER_GUIDE.md + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP tools now publish stability markers (#1696)** — every advertised tool includes `x-stability`, with experimental tools marked explicitly in `tools/list`. + +## 日本語 + +- **MCP tool が stability marker を公開するようになりました (#1696)** — 公開される全 tool に `x-stability` を追加し、experimental な tool は `tools/list` で明示します。 diff --git a/changelog.d/unreleased/1723.changed.md b/changelog.d/unreleased/1723.changed.md new file mode 100644 index 0000000000..345949fc56 --- /dev/null +++ b/changelog.d/unreleased/1723.changed.md @@ -0,0 +1,17 @@ +--- +category: changed +issues: + - 1723 +affected: + - src/CodeIndex/Mcp/McpServer.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP structured JSON now uses snake_case payload keys (#1723)** — manually assembled SQL/exact-signal payloads now align with the CLI JSON naming convention while preserving MCP protocol envelope fields. + +## 日本語 + +- **MCP の構造化 JSON payload key を snake_case に統一しました (#1723)** — MCP protocol envelope field は維持しつつ、手組みの SQL / exact signal payload が CLI JSON の命名規則と揃いました。 diff --git a/changelog.d/unreleased/1778.fixed.md b/changelog.d/unreleased/1778.fixed.md new file mode 100644 index 0000000000..4e55b78980 --- /dev/null +++ b/changelog.d/unreleased/1778.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 1778 +affected: + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP tools now reject misspelled arguments (#1778)** — `tools/list` schemas advertise `additionalProperties: false`, and `tools/call` returns an invalid-argument error when a request includes an unknown field. + +## 日本語 + +- **MCP tool が綴り間違いの引数を拒否するようになりました (#1778)** — `tools/list` schema は `additionalProperties: false` を公開し、`tools/call` は未知の field を含むリクエストに invalid-argument error を返します。 diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index f1fedbf032..9d377b2134 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -1999,7 +1999,16 @@ private async Task HandleToolsCallAsync(JsonNode? id, JsonNode? callPa JsonNode response; try { - if (ValidateCommonListArguments(args) is JsonObject listArgumentError) + if (ValidateToolArguments(toolName, args) is JsonObject argumentError) + { + metricsError = "invalid_argument"; + response = CreateToolErrorResponse(id, argumentError["message"]!.GetValue(), + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "Use exactly the argument names advertised by tools/list for this tool.", + retrySafe: false, + extraData: argumentError); + } + else if (ValidateCommonListArguments(args) is JsonObject listArgumentError) { metricsError = "invalid_list_argument"; response = CreateToolErrorResponse(id, listArgumentError["message"]!.GetValue(), diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 3db42960fd..334815b6ee 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -99,7 +99,7 @@ private JsonNode HandleToolsList(JsonNode? id) ReadOnlyAnnotations()), CreateToolDefinition( "callers", - "Find caller symbols that reference a callee. For exact matches, use `exactName`; `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. When `kind` is omitted, call-graph kinds (`call`, `instantiate`, `subscribe`, `friend`) are returned so C++ friend access/coupling edges stay visible while metadata uses (`attribute` / `annotation`) and compile-time type-position references (`type_reference`) do not pollute caller edges; identical constructor `call` + `instantiate` rows at one physical site also collapse. Each grouped row additionally exposes `referenceKinds` (sorted distinct kinds behind the row) and `hasMixedReferenceKinds` so callers do not have to trust the single summary label when a container mixes `call` + `subscribe` edges. The existing `referenceKind` scalar is retained for back-compat and carries the preferred summary kind (`instantiate` > `subscribe` > `unsubscribe` > `MIN(kind)`). `callers` / `callees` are not a reliable path to metadata or type-position references — metadata rows are attributed to their enclosing body-range symbol (for a class-level declaration, that is the class itself; for a file-level target such as `[assembly: ...]`, `containerName` is `null` and the row drops from these graph queries entirely), and `type_reference` rows are compile-time type mentions (declaration types, generic constraints, `is`/`as`/`instanceof`, XML-doc `cref`) rather than runtime calls. Use `references` with `kind: \"attribute\"`, `\"annotation\"`, or `\"type_reference\"` instead. Examples: `callers {\"query\":\"HandleRequest\"}`; `callers {\"query\":\"ExecuteAsync\",\"kind\":\"call\",\"rankBy\":\"weighted\",\"lang\":\"csharp\"}`. / 指定シンボルを参照している呼び出し元シンボルを探す。完全一致には `exactName` を使う。`exact` は USER_GUIDE.md の flag compatibility table に記載された legacy alias。`kind` 未指定時は call-graph 種別 (`call` / `instantiate` / `subscribe` / `friend`) を返し、C++ friend の access/coupling edge は可視化しつつ、metadata 使用 (`attribute` / `annotation`) と compile-time な型位置参照 (`type_reference`) が phantom caller edge として混入しないようにする。同じ物理位置にある constructor の `call` + `instantiate` 重複行も集約する。各グループ行には `referenceKinds`(行内の distinct kind をソートした配列)と `hasMixedReferenceKinds` も追加で返すため、container が `call` + `subscribe` を混在させている行で要約 1 ラベルに騙されずに済む。既存のスカラー `referenceKind` は後方互換のため維持され、優先サマリー種別(`instantiate` > `subscribe` > `unsubscribe` > `MIN(kind)`)を持つ。metadata 行の container は注釈対象そのものではなく body-range 上の外側シンボル(クラス直下宣言ならクラス、ファイルレベル target なら `null`)になり、`type_reference` は実行時呼び出しではなく宣言型・generic 制約・`is`/`as`/`instanceof`・XML-doc `cref` といった compile-time な型言及なので、`callers` / `callees` は metadata / 型位置参照の列挙に向かない。Metadata / 型位置参照の列挙は `references --kind attribute|annotation|type_reference` / MCP `references` を使う。例: `callers {\"query\":\"HandleRequest\"}`; `callers {\"query\":\"ExecuteAsync\",\"kind\":\"call\",\"rankBy\":\"weighted\",\"lang\":\"csharp\"}`。", + "Find caller symbols that reference a callee. For exact matches, use `exactName`; `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. When `kind` is omitted, call-graph kinds (`call`, `instantiate`, `subscribe`, `friend`) are returned so C++ friend access/coupling edges stay visible while metadata uses (`attribute` / `annotation`) and compile-time type-position references (`type_reference`) do not pollute caller edges; identical constructor `call` + `instantiate` rows at one physical site also collapse. Each grouped row additionally exposes `reference_kinds` (sorted distinct kinds behind the row) and `has_mixed_reference_kinds` so callers do not have to trust the single summary label when a container mixes `call` + `subscribe` edges. The existing `reference_kind` scalar is retained for back-compat and carries the preferred summary kind (`instantiate` > `subscribe` > `unsubscribe` > `MIN(kind)`). `callers` / `callees` are not a reliable path to metadata or type-position references — metadata rows are attributed to their enclosing body-range symbol (for a class-level declaration, that is the class itself; for a file-level target such as `[assembly: ...]`, `containerName` is `null` and the row drops from these graph queries entirely), and `type_reference` rows are compile-time type mentions (declaration types, generic constraints, `is`/`as`/`instanceof`, XML-doc `cref`) rather than runtime calls. Use `references` with `kind: \"attribute\"`, `\"annotation\"`, or `\"type_reference\"` instead. Examples: `callers {\"query\":\"HandleRequest\"}`; `callers {\"query\":\"ExecuteAsync\",\"kind\":\"call\",\"rankBy\":\"weighted\",\"lang\":\"csharp\"}`. / 指定シンボルを参照している呼び出し元シンボルを探す。完全一致には `exactName` を使う。`exact` は USER_GUIDE.md の flag compatibility table に記載された legacy alias。`kind` 未指定時は call-graph 種別 (`call` / `instantiate` / `subscribe` / `friend`) を返し、C++ friend の access/coupling edge は可視化しつつ、metadata 使用 (`attribute` / `annotation`) と compile-time な型位置参照 (`type_reference`) が phantom caller edge として混入しないようにする。同じ物理位置にある constructor の `call` + `instantiate` 重複行も集約する。各グループ行には `reference_kinds`(行内の distinct kind をソートした配列)と `has_mixed_reference_kinds` も追加で返すため、container が `call` + `subscribe` を混在させている行で要約 1 ラベルに騙されずに済む。既存のスカラー `reference_kind` は後方互換のため維持され、優先サマリー種別(`instantiate` > `subscribe` > `unsubscribe` > `MIN(kind)`)を持つ。metadata 行の container は注釈対象そのものではなく body-range 上の外側シンボル(クラス直下宣言ならクラス、ファイルレベル target なら `null`)になり、`type_reference` は実行時呼び出しではなく宣言型・generic 制約・`is`/`as`/`instanceof`・XML-doc `cref` といった compile-time な型言及なので、`callers` / `callees` は metadata / 型位置参照の列挙に向かない。Metadata / 型位置参照の列挙は `references --kind attribute|annotation|type_reference` / MCP `references` を使う。例: `callers {\"query\":\"HandleRequest\"}`; `callers {\"query\":\"ExecuteAsync\",\"kind\":\"call\",\"rankBy\":\"weighted\",\"lang\":\"csharp\"}`。", new JsonObject { ["type"] = "object", @@ -124,7 +124,7 @@ private JsonNode HandleToolsList(JsonNode? id) ReadOnlyAnnotations()), CreateToolDefinition( "callees", - "Find callees used by a caller/container symbol. For exact matches, use `exactName`; `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. When `kind` is omitted, call-graph kinds (`call`, `instantiate`, `subscribe`, `friend`) are returned so C++ friend access/coupling edges stay visible while metadata uses (`attribute` / `annotation`) and compile-time type-position references (`type_reference`) do not pollute callee edges; identical constructor `call` + `instantiate` rows at one physical site also collapse. Each grouped row additionally exposes `referenceKinds` (sorted distinct kinds behind the row) and `hasMixedReferenceKinds` for symmetry with `callers`, even though rows are already split per kind on this side. The existing `referenceKind` scalar is retained for back-compat and carries the same kind value. `callees` is not a reliable path to metadata or type-position references — the container assigned to an attribute / annotation row is the enclosing body-range symbol, not the annotated declaration, so `callees Method1 --kind attribute` does not return the attributes on `Method1`, and `type_reference` rows are compile-time type mentions (declaration types, generic constraints, `is`/`as`/`instanceof`, XML-doc `cref`) rather than runtime calls. Use `references` with `kind: \"attribute\"`, `\"annotation\"`, or `\"type_reference\"` instead. Examples: `callees {\"query\":\"Run\"}`; `callees {\"query\":\"Program.Main\",\"kind\":\"instantiate\",\"lang\":\"csharp\",\"limit\":10}`. / 呼び出し元シンボルが使っている呼び出し先を探す。完全一致には `exactName` を使う。`exact` は USER_GUIDE.md の flag compatibility table に記載された legacy alias。`kind` 未指定時は call-graph 種別 (`call` / `instantiate` / `subscribe` / `friend`) を返し、C++ friend の access/coupling edge は可視化しつつ、metadata 使用 (`attribute` / `annotation`) と compile-time な型位置参照 (`type_reference`) が phantom callee edge として混入しないようにする。同じ物理位置にある constructor の `call` + `instantiate` 重複行も集約する。各グループ行には `callers` との対称性のため `referenceKinds`(行内の distinct kind をソートした配列)と `hasMixedReferenceKinds` も返る(`callees` 側は元々 kind ごとに行を分けているため通常は単一要素)。既存のスカラー `referenceKind` は後方互換のため維持され、同じ kind 値を持つ。metadata 行の container は注釈対象自身ではなく body-range 上の外側シンボルになるため、`callees` で `Method1 --kind attribute` を引いても `Method1` に付いた属性は返らない。`type_reference` は実行時呼び出しではなく宣言型・generic 制約・`is`/`as`/`instanceof`・XML-doc `cref` といった compile-time な型言及なので、`callees` は metadata / 型位置参照の列挙に向かない。Metadata / 型位置参照の列挙は `references --kind attribute|annotation|type_reference` / MCP `references` を使う。例: `callees {\"query\":\"Run\"}`; `callees {\"query\":\"Program.Main\",\"kind\":\"instantiate\",\"lang\":\"csharp\",\"limit\":10}`。", + "Find callees used by a caller/container symbol. For exact matches, use `exactName`; `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. When `kind` is omitted, call-graph kinds (`call`, `instantiate`, `subscribe`, `friend`) are returned so C++ friend access/coupling edges stay visible while metadata uses (`attribute` / `annotation`) and compile-time type-position references (`type_reference`) do not pollute callee edges; identical constructor `call` + `instantiate` rows at one physical site also collapse. Each grouped row additionally exposes `reference_kinds` (sorted distinct kinds behind the row) and `has_mixed_reference_kinds` for symmetry with `callers`, even though rows are already split per kind on this side. The existing `reference_kind` scalar is retained for back-compat and carries the same kind value. `callees` is not a reliable path to metadata or type-position references — the container assigned to an attribute / annotation row is the enclosing body-range symbol, not the annotated declaration, so `callees Method1 --kind attribute` does not return the attributes on `Method1`, and `type_reference` rows are compile-time type mentions (declaration types, generic constraints, `is`/`as`/`instanceof`, XML-doc `cref`) rather than runtime calls. Use `references` with `kind: \"attribute\"`, `\"annotation\"`, or `\"type_reference\"` instead. Examples: `callees {\"query\":\"Run\"}`; `callees {\"query\":\"Program.Main\",\"kind\":\"instantiate\",\"lang\":\"csharp\",\"limit\":10}`. / 呼び出し元シンボルが使っている呼び出し先を探す。完全一致には `exactName` を使う。`exact` は USER_GUIDE.md の flag compatibility table に記載された legacy alias。`kind` 未指定時は call-graph 種別 (`call` / `instantiate` / `subscribe` / `friend`) を返し、C++ friend の access/coupling edge は可視化しつつ、metadata 使用 (`attribute` / `annotation`) と compile-time な型位置参照 (`type_reference`) が phantom callee edge として混入しないようにする。同じ物理位置にある constructor の `call` + `instantiate` 重複行も集約する。各グループ行には `callers` との対称性のため `reference_kinds`(行内の distinct kind をソートした配列)と `has_mixed_reference_kinds` も返る(`callees` 側は元々 kind ごとに行を分けているため通常は単一要素)。既存のスカラー `reference_kind` は後方互換のため維持され、同じ kind 値を持つ。metadata 行の container は注釈対象自身ではなく body-range 上の外側シンボルになるため、`callees` で `Method1 --kind attribute` を引いても `Method1` に付いた属性は返らない。`type_reference` は実行時呼び出しではなく宣言型・generic 制約・`is`/`as`/`instanceof`・XML-doc `cref` といった compile-time な型言及なので、`callees` は metadata / 型位置参照の列挙に向かない。Metadata / 型位置参照の列挙は `references --kind attribute|annotation|type_reference` / MCP `references` を使う。例: `callees {\"query\":\"Run\"}`; `callees {\"query\":\"Program.Main\",\"kind\":\"instantiate\",\"lang\":\"csharp\",\"limit\":10}`。", new JsonObject { ["type"] = "object", @@ -251,7 +251,7 @@ private JsonNode HandleToolsList(JsonNode? id) ReadOnlyAnnotations()), CreateToolDefinition( "analyze_symbol", - "Bundle definition, nearby symbols, references, callers, callees, file metadata, and graph-support metadata for one symbol query. For exact matches, use `exactName`; `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. Bundled caller/callee rows carry the same `referenceKind` (preferred summary kind, back-compat) plus `referenceKinds` (sorted distinct) and `hasMixedReferenceKinds` fields as the standalone `callers` / `callees` tools, so mixed `call` + `subscribe` containers stay visible in the bundle. / 1つのシンボルクエリに対して、定義、近傍シンボル、参照、caller、callee、ファイルメタデータ、グラフ対応メタデータをまとめて返す。完全一致には `exactName` を使う。`exact` は USER_GUIDE.md の flag compatibility table に記載された legacy alias。バンドルされた caller / callee 行にも単独の `callers` / `callees` と同じ `referenceKind`(後方互換の優先サマリー種別)、`referenceKinds`(distinct kind の昇順配列)、`hasMixedReferenceKinds` が付くため、`call` + `subscribe` が混在するコンテナも要約 1 ラベルに潰れず見える。", + "Bundle definition, nearby symbols, references, callers, callees, file metadata, and graph-support metadata for one symbol query. For exact matches, use `exactName`; `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. Bundled caller/callee rows carry the same `reference_kind` (preferred summary kind, back-compat) plus `reference_kinds` (sorted distinct) and `has_mixed_reference_kinds` fields as the standalone `callers` / `callees` tools, so mixed `call` + `subscribe` containers stay visible in the bundle. / 1つのシンボルクエリに対して、定義、近傍シンボル、参照、caller、callee、ファイルメタデータ、グラフ対応メタデータをまとめて返す。完全一致には `exactName` を使う。`exact` は USER_GUIDE.md の flag compatibility table に記載された legacy alias。バンドルされた caller / callee 行にも単独の `callers` / `callees` と同じ `reference_kind`(後方互換の優先サマリー種別)、`reference_kinds`(distinct kind の昇順配列)、`has_mixed_reference_kinds` が付くため、`call` + `subscribe` が混在するコンテナも要約 1 ラベルに潰れず見える。", new JsonObject { ["type"] = "object", @@ -274,7 +274,7 @@ private JsonNode HandleToolsList(JsonNode? id) ReadOnlyAnnotations()), CreateToolDefinition( "impact_analysis", - "Compute the transitive caller chain for a symbol. The symbol-level BFS walks only call-graph kinds (`call`, `instantiate`, `subscribe`) and excludes metadata-only edges (`attribute`, `annotation`, `type_reference`) so metadata cycles do not inflate caller counts. Multiple edge kinds from the same caller to the same target are counted and returned separately, with `referenceKind`, `referenceKinds`, and `referenceKindCounts` on each caller row. When a scoped query resolves to a single class / struct / interface but no symbol-level callers exist, may return heuristic file-level dependency hints instead; those file hints can include metadata edges, so check `impact_mode`, `heuristic`, and `file_impacts`. When `truncated` is true, inspect `truncated_reason` (`user_limit` means raising `limit` returns more; `safety_cap` means the graph is likely pathological and raising `limit` will not help). Pass `withPaths: true` when you need the call chain via specific intermediates — each caller then carries a `paths` array of shortest routes (issue #1536). / シンボルの推移的呼び出しチェーンを算出。symbol-level BFS は call graph 種別(`call`、`instantiate`、`subscribe`)のみを辿り、metadata-only edge(`attribute`、`annotation`、`type_reference`)を除外するため、metadata cycle で caller 件数が膨らまない。同じ caller から同じ target への複数 edge kind は別々に数えて返し、各 caller 行に `referenceKind`、`referenceKinds`、`referenceKindCounts` が付く。scoped query が単一の class / struct / interface に解決されても symbol-level caller が無い場合は、代わりに heuristic な file-level dependency hint を返すことがある。この file hint は metadata edge を含み得るため、`impact_mode`・`heuristic`・`file_impacts` を確認すること。`truncated` が真のときは `truncated_reason` を見て、`user_limit` なら `limit` を増やせば残りも取得可能、`safety_cap` ならグラフが病的で `limit` を増やしても解消しないことを区別すること。中間シンボル経由の経路が必要な場合は `withPaths: true` を渡すと、各 caller に経路配列 `paths` が付く(issue #1536)。", + "Compute the transitive caller chain for a symbol. The symbol-level BFS walks only call-graph kinds (`call`, `instantiate`, `subscribe`) and excludes metadata-only edges (`attribute`, `annotation`, `type_reference`) so metadata cycles do not inflate caller counts. Multiple edge kinds from the same caller to the same target are counted and returned separately, with `reference_kind`, `reference_kinds`, and `reference_kindCounts` on each caller row. When a scoped query resolves to a single class / struct / interface but no symbol-level callers exist, may return heuristic file-level dependency hints instead; those file hints can include metadata edges, so check `impact_mode`, `heuristic`, and `file_impacts`. When `truncated` is true, inspect `truncated_reason` (`user_limit` means raising `limit` returns more; `safety_cap` means the graph is likely pathological and raising `limit` will not help). Pass `withPaths: true` when you need the call chain via specific intermediates — each caller then carries a `paths` array of shortest routes (issue #1536). / シンボルの推移的呼び出しチェーンを算出。symbol-level BFS は call graph 種別(`call`、`instantiate`、`subscribe`)のみを辿り、metadata-only edge(`attribute`、`annotation`、`type_reference`)を除外するため、metadata cycle で caller 件数が膨らまない。同じ caller から同じ target への複数 edge kind は別々に数えて返し、各 caller 行に `reference_kind`、`reference_kinds`、`reference_kindCounts` が付く。scoped query が単一の class / struct / interface に解決されても symbol-level caller が無い場合は、代わりに heuristic な file-level dependency hint を返すことがある。この file hint は metadata edge を含み得るため、`impact_mode`・`heuristic`・`file_impacts` を確認すること。`truncated` が真のときは `truncated_reason` を見て、`user_limit` なら `limit` を増やせば残りも取得可能、`safety_cap` ならグラフが病的で `limit` を増やしても解消しないことを区別すること。中間シンボル経由の経路が必要な場合は `withPaths: true` を渡すと、各 caller に経路配列 `paths` が付く(issue #1536)。", new JsonObject { ["type"] = "object", @@ -560,16 +560,32 @@ private static void AddCommonSchemaConstraints(JsonArray tools) { foreach (var tool in tools.OfType()) { - var properties = tool["inputSchema"]?["properties"] as JsonObject; + var inputSchema = tool["inputSchema"] as JsonObject; + inputSchema?.TryAdd("additionalProperties", false); + var toolName = tool["name"]?.GetValue() ?? string.Empty; + var stability = GetToolStability(toolName); + tool["x-stability"] = stability; + if (stability != "stable" && tool["description"]?.GetValue() is { } description + && !description.StartsWith($"[{stability}]", StringComparison.Ordinal)) + { + tool["description"] = $"[{stability}] {description}"; + } + + var properties = inputSchema?["properties"] as JsonObject; if (properties == null) continue; - var toolName = tool["name"]?.GetValue() ?? string.Empty; foreach (var (name, schema) in properties) ApplyCommonSchemaConstraint(toolName, name, schema); } } + private static string GetToolStability(string toolName) => toolName switch + { + "validate" or "impact_analysis" or "backfill_fold" or "suggest_improvement" => "experimental", + _ => "stable", + }; + private static void ApplyCommonSchemaConstraint(string toolName, string name, JsonNode? schema) { if (schema is not JsonObject obj) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 55b69080f3..a7f42aa0c0 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -264,6 +264,79 @@ private static List ReadStringList(JsonNode? args, string propertyName) return null; } + private static JsonObject? ValidateToolArguments(string toolName, JsonNode? args) + { + if (!IsKnownToolName(toolName)) + return null; + + if (args is null) + return null; + if (args is not JsonObject obj) + return new JsonObject + { + ["message"] = "Tool arguments must be a JSON object.", + ["tool"] = toolName, + }; + + var allowed = GetAllowedToolArguments(toolName); + if (allowed.Count == 0) + return obj.Count == 0 ? null : new JsonObject + { + ["message"] = $"Tool '{toolName}' does not accept arguments.", + ["tool"] = toolName, + ["unknown_argument"] = obj.First().Key, + }; + + foreach (var property in obj) + { + if (!allowed.Contains(property.Key)) + { + return new JsonObject + { + ["message"] = $"Unknown argument '{property.Key}' for tool '{toolName}'.", + ["tool"] = toolName, + ["unknown_argument"] = property.Key, + }; + } + } + + return null; + } + + private static bool IsKnownToolName(string toolName) => toolName switch + { + "search" or "definition" or "references" or "callers" or "callees" or "symbols" or + "files" or "find_in_file" or "excerpt" or "map" or "analyze_symbol" or "status" or + "outline" or "batch_query" or "deps" or "impact_analysis" or "languages" or "validate" or + "unused_symbols" or "symbol_hotspots" or "ping" or "index" or "backfill_fold" or + "suggest_improvement" => true, + _ => false, + }; + + private static IReadOnlySet GetAllowedToolArguments(string toolName) => toolName switch + { + "search" => new HashSet(StringComparer.Ordinal) { "query", "limit", "lang", "snippetLines", "maxLineWidth", "rawQuery", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "noDedup", "exactSubstring", "exact", "prefix", "countOnly", "project", "solution" }, + "definition" => new HashSet(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "includeBody", "lsp_compatible", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "project", "solution" }, + "references" => new HashSet(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "offset", "maxLineWidth", "lsp_compatible", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "project", "solution" }, + "callers" or "callees" => new HashSet(StringComparer.Ordinal) { "query", "kind", "rankBy", "lang", "limit", "offset", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "project", "solution" }, + "symbols" => new HashSet(StringComparer.Ordinal) { "query", "names", "kind", "lang", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "project", "solution" }, + "files" => new HashSet(StringComparer.Ordinal) { "query", "lang", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "since" }, + "find_in_file" => new HashSet(StringComparer.Ordinal) { "query", "path", "limit", "lang", "excludePaths", "excludeTests", "includeGenerated", "before", "after", "maxLineWidth", "exact" }, + "excerpt" => new HashSet(StringComparer.Ordinal) { "path", "startLine", "endLine", "before", "after", "focusLine", "focusColumn", "focusLength", "maxLineWidth" }, + "map" => new HashSet(StringComparer.Ordinal) { "limit", "lang", "path", "excludePaths", "excludeTests", "project", "solution" }, + "analyze_symbol" => new HashSet(StringComparer.Ordinal) { "query", "lang", "limit", "includeBody", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "maxLineWidth", "project", "solution" }, + "outline" => new HashSet(StringComparer.Ordinal) { "path", "limit", "includeImports", "maxLineWidth" }, + "batch_query" => new HashSet(StringComparer.Ordinal) { "queries" }, + "deps" => new HashSet(StringComparer.Ordinal) { "path", "direction", "lang", "limit", "excludePaths", "excludeTests", "project", "solution" }, + "impact_analysis" => new HashSet(StringComparer.Ordinal) { "symbol", "query", "lang", "maxHops", "maxDepth", "depth", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "withPaths", "countOnly", "project", "solution" }, + "validate" => new HashSet(StringComparer.Ordinal) { "path", "lang", "limit", "excludePaths", "excludeTests", "project", "solution" }, + "unused_symbols" => new HashSet(StringComparer.Ordinal) { "kind", "lang", "limit", "path", "excludePaths", "excludeTests", "project", "solution" }, + "symbol_hotspots" => new HashSet(StringComparer.Ordinal) { "kind", "lang", "limit", "groupBy", "path", "excludePaths", "excludeTests", "project", "solution" }, + "index" => new HashSet(StringComparer.Ordinal) { "path", "db", "rebuild", "parallelism", "files", "commits", "changedBetween", "dryRun", "optimize" }, + "suggest_improvement" => new HashSet(StringComparer.Ordinal) { "category", "language", "description", "context", "toolInvocationContext" }, + _ => new HashSet(StringComparer.Ordinal), + }; + private static JsonObject? ValidateStringListArgument(JsonNode? args, string propertyName) { var node = args?[propertyName]; @@ -479,39 +552,39 @@ private JsonObject ToAnalyzeSymbolJsonObject(SymbolAnalysisResult analysis) ["api_version"] = analysis.ApiVersion, ["query"] = analysis.Query, ["file"] = JsonSerializer.SerializeToNode(analysis.File, _jsonOptions), - ["workspaceIndexedAt"] = JsonSerializer.SerializeToNode(analysis.WorkspaceIndexedAt, _jsonOptions), - ["workspaceLatestModified"] = JsonSerializer.SerializeToNode(analysis.WorkspaceLatestModified, _jsonOptions), - ["projectRoot"] = analysis.ProjectRoot, - ["gitHead"] = analysis.GitHead, - ["gitIsDirty"] = analysis.GitIsDirty, - ["graphLanguage"] = analysis.GraphLanguage, - ["graphSupported"] = analysis.GraphSupported, - ["graphSupportReason"] = analysis.GraphSupportReason, + ["workspace_indexed_at"] = JsonSerializer.SerializeToNode(analysis.WorkspaceIndexedAt, _jsonOptions), + ["workspace_latest_modified"] = JsonSerializer.SerializeToNode(analysis.WorkspaceLatestModified, _jsonOptions), + ["project_root"] = analysis.ProjectRoot, + ["git_head"] = analysis.GitHead, + ["git_is_dirty"] = analysis.GitIsDirty, + ["graph_language"] = analysis.GraphLanguage, + ["graph_supported"] = analysis.GraphSupported, + ["graph_support_reason"] = analysis.GraphSupportReason, ["definitions"] = ToJsonArray(analysis.Definitions), - ["nearbySymbols"] = ToJsonArray(analysis.NearbySymbols), + ["nearby_symbols"] = ToJsonArray(analysis.NearbySymbols), ["references"] = ToJsonArray(analysis.References), ["callers"] = ToJsonArray(analysis.Callers), ["callees"] = ToJsonArray(analysis.Callees), - ["graphTableAvailable"] = analysis.GraphTableAvailable, + ["graph_table_available"] = analysis.GraphTableAvailable, }; if (analysis.IndexedHeadCommit != null) payload["indexed_head_commit"] = analysis.IndexedHeadCommit; if (analysis.WorktreeHeadChanged.HasValue) payload["worktree_head_changed"] = analysis.WorktreeHeadChanged.Value; if (analysis.GraphDegraded.HasValue) - payload["graphDegraded"] = analysis.GraphDegraded.Value; + payload["graph_degraded"] = analysis.GraphDegraded.Value; if (analysis.UnsupportedSymbolKind != null) - payload["unsupportedSymbolKind"] = analysis.UnsupportedSymbolKind; + payload["unsupported_symbol_kind"] = analysis.UnsupportedSymbolKind; if (analysis.SqlGraphContractReady.HasValue) - payload["sqlGraphContractReady"] = analysis.SqlGraphContractReady.Value; + payload["sql_graph_contract_ready"] = analysis.SqlGraphContractReady.Value; if (analysis.SqlGraphContractDegradedReason != null) - payload["sqlGraphContractDegradedReason"] = analysis.SqlGraphContractDegradedReason; + payload["sql_graph_contract_degraded_reason"] = analysis.SqlGraphContractDegradedReason; if (analysis.ExactZeroHint != null) - payload["exactZeroHint"] = JsonSerializer.SerializeToNode(analysis.ExactZeroHint, _jsonOptions); + payload["exact_zero_hint"] = JsonSerializer.SerializeToNode(analysis.ExactZeroHint, _jsonOptions); if (analysis.ExactIndexAvailable.HasValue) - payload["exactIndexAvailable"] = analysis.ExactIndexAvailable.Value; + payload["exact_index_available"] = analysis.ExactIndexAvailable.Value; if (analysis.DegradedReason != null) - payload["degradedReason"] = analysis.DegradedReason; + payload["degraded_reason"] = analysis.DegradedReason; return payload; } @@ -974,9 +1047,9 @@ private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args) ["maxLineWidth"] = maxLineWidth, ["path"] = PathEcho(pathPatterns), ["excludeTests"] = excludeTests, - ["graphLanguage"] = graphSupport.GraphLanguage, - ["graphSupported"] = graphSupport.GraphSupported, - ["graphSupportReason"] = graphSupport.GraphSupportReason, + ["graph_language"] = graphSupport.GraphLanguage, + ["graph_supported"] = graphSupport.GraphSupported, + ["graph_support_reason"] = graphSupport.GraphSupportReason, ["results"] = ToJsonArray(results) }; AddPaginatedResultEnvelope(payload, results.Count, total, truncated, offset); @@ -1061,9 +1134,9 @@ private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) ["path"] = PathEcho(pathPatterns), ["excludeTests"] = excludeTests, ["rankBy"] = QueryCommandRunner.FormatReferenceRankMode(rankMode), - ["graphLanguage"] = graphSupport.GraphLanguage, - ["graphSupported"] = graphSupport.GraphSupported, - ["graphSupportReason"] = graphSupport.GraphSupportReason, + ["graph_language"] = graphSupport.GraphLanguage, + ["graph_supported"] = graphSupport.GraphSupported, + ["graph_support_reason"] = graphSupport.GraphSupportReason, ["results"] = ToJsonArray(results) }; AddPaginatedResultEnvelope(payload, results.Count, total, truncated, offset); @@ -1149,9 +1222,9 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) ["path"] = PathEcho(pathPatterns), ["excludeTests"] = excludeTests, ["rankBy"] = QueryCommandRunner.FormatReferenceRankMode(rankMode), - ["graphLanguage"] = graphSupport.GraphLanguage, - ["graphSupported"] = graphSupport.GraphSupported, - ["graphSupportReason"] = graphSupport.GraphSupportReason, + ["graph_language"] = graphSupport.GraphLanguage, + ["graph_supported"] = graphSupport.GraphSupported, + ["graph_support_reason"] = graphSupport.GraphSupportReason, ["results"] = ToJsonArray(results) }; AddPaginatedResultEnvelope(payload, results.Count, total, truncated, offset); @@ -1284,7 +1357,6 @@ private JsonNode ExecuteAnalyzeSymbol(JsonNode? id, JsonNode? args) analysis.SqlGraphContractDegradedReason = sqlGraphSignal.Relevant ? sqlGraphSignal.DegradedReason : null; WorkspaceMetadataEnricher.Enrich(analysis, _dbPath, _dbPathExplicit); var structured = ToAnalyzeSymbolJsonObject(analysis); - AddExactSignalAliases(structured); AddSqlGraphContractSignal(structured, sqlGraphSignal); structured.Remove("exactZeroHint"); AddExactZeroHint(structured, analysis.ExactZeroHint); @@ -1312,7 +1384,7 @@ private static void AddExactGraphSignal(JsonObject payload, ExactQuerySignal sig payload["exact_index_available"] = signal.ExactIndexAvailable; if (signal.DegradedReason != null) payload["degraded_reason"] = signal.DegradedReason; - AddExactSignalAliases(payload); + // MCP uses snake_case response keys consistently; do not add camelCase aliases here. } private static void AddSqlGraphContractSignal(JsonObject payload, SqlGraphContractSignal signal) @@ -1321,14 +1393,12 @@ private static void AddSqlGraphContractSignal(JsonObject payload, SqlGraphContra return; payload["sql_graph_contract_ready"] = signal.Ready; - payload["sqlGraphContractReady"] = signal.Ready; if (!signal.Ready) { payload["degraded"] = true; if (signal.DegradedReason != null) { payload["sql_graph_contract_degraded_reason"] = signal.DegradedReason; - payload["sqlGraphContractDegradedReason"] = signal.DegradedReason; } } } @@ -1476,14 +1546,12 @@ private static bool AllowReuseWithCurrentHotspotFamilyTrust( private static void AddHotspotFamilySignal(JsonObject payload, HotspotFamilySignal signal) { payload["hotspot_family_ready"] = signal.Ready; - payload["hotspotFamilyReady"] = signal.Ready; if (!signal.Ready) { payload["degraded"] = true; if (signal.DegradedReason != null) { payload["hotspot_family_degraded_reason"] = signal.DegradedReason; - payload["hotspotFamilyDegradedReason"] = signal.DegradedReason; } } } @@ -1516,12 +1584,14 @@ private JsonNode ExecuteStatus(JsonNode? id) status.AlternativeAction = BuildFoldRebuildRepairCommand(status.ProjectRoot, _dbPath, _dbPathExplicit); } var structured = JsonSerializer.SerializeToNode(status, _jsonOptions)!.AsObject(); - structured["hotspotFamilyReady"] = status.HotspotFamilyReady; - if (status.HotspotFamilyDegradedReason != null) - structured["hotspotFamilyDegradedReason"] = status.HotspotFamilyDegradedReason; - structured["sqlGraphContractReady"] = status.SqlGraphContractReady; + structured["project_root"] = status.ProjectRoot; + structured["git_head"] = status.GitHead; + structured["git_is_dirty"] = status.GitIsDirty; + structured.Remove("hotspotFamilyReady"); + structured.Remove("hotspotFamilyDegradedReason"); + structured["sql_graph_contract_ready"] = status.SqlGraphContractReady; if (status.SqlGraphContractDegradedReason != null) - structured["sqlGraphContractDegradedReason"] = status.SqlGraphContractDegradedReason; + structured["sql_graph_contract_degraded_reason"] = status.SqlGraphContractDegradedReason; structured["mcp_session"] = BuildMcpSessionStatus(); structured["mcp"] = new JsonObject { @@ -1933,6 +2003,15 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg continue; } + if (ValidateToolArguments(toolName, toolArgs) is JsonObject argumentError) + { + AppendSlotError(requestIndex, toolName, toolArgs, slotStopwatch, argumentError["message"]!.GetValue(), + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "Use exactly the argument names advertised by tools/list for this tool.", + retrySafe: false); + continue; + } + if (ValidateCommonListArguments(toolArgs) is JsonObject listArgumentError) { AppendSlotError(requestIndex, toolName, toolArgs, slotStopwatch, listArgumentError["message"]!.GetValue(), @@ -3084,7 +3163,6 @@ void WriteProjectRootOnce() ["errors"] = errors }, ["sql_graph_contract_ready"] = sqlGraphContractReadyAfter, - ["sqlGraphContractReady"] = sqlGraphContractReadyAfter, ["csharp_symbol_name_ready"] = csharpSymbolNameReadyAfter, ["csharp_metadata_target_ready"] = csharpMetadataTargetReadyAfter, // #86 codex review: AI clients use this to tell whether --exact will use the diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 79286769e6..5cd4ca9c5e 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -2189,6 +2189,37 @@ public void ToolsList_SearchHasRequiredQueryParam() Assert.Contains("query", required.Select(r => r!.GetValue())); } + [Fact] + public void ToolsList_EveryInputSchemaRejectsAdditionalPropertiesAndPublishesStability() + { + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; + var response = _server.HandleMessage(request)!; + + var tools = response["result"]!["tools"]!.AsArray(); + foreach (var tool in tools) + { + Assert.False(tool!["inputSchema"]!["additionalProperties"]!.GetValue()); + Assert.Contains(tool["x-stability"]!.GetValue(), new[] { "stable", "experimental", "deprecated" }); + } + + var impact = tools.First(t => t!["name"]!.GetValue() == "impact_analysis")!; + Assert.Equal("experimental", impact["x-stability"]!.GetValue()); + } + + [Fact] + public void ToolsCall_UnknownArgument_ReturnsInvalidParams() + { + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"limt":1,"query":"abc"}}}""")!; + + var response = _server.HandleMessage(request)!; + + var result = response["result"]!; + Assert.True(result["isError"]!.GetValue()); + var structured = result["structuredContent"]!; + Assert.Equal("invalid_argument", structured["category"]!.GetValue()); + Assert.Equal("limt", structured["unknown_argument"]!.GetValue()); + } + [Fact] public void ToolsList_SearchIncludesPathFilterParams() { @@ -2309,15 +2340,9 @@ public void ToolsList_CallersCalleesKindDescription_ExcludesMetadataKinds() } [Fact] - public void ToolsList_CallersCalleesAnalyzeSymbolDescriptions_PinCamelCaseMixedKindFields() + public void ToolsList_CallersCalleesAnalyzeSymbolDescriptions_PinSnakeCaseMixedKindFields() { - // #501 round 2: MCP tool descriptions must advertise the response fields in MCP camelCase - // (`referenceKind`, `referenceKinds`, `hasMixedReferenceKinds`) because MCP serializes with - // `JsonNamingPolicy.CamelCase`. This test pins those field names so a future edit that - // accidentally switches back to CLI snake_case is caught before it reaches MCP consumers. - // #501 round 2: MCP は `JsonNamingPolicy.CamelCase` でシリアライズするため、ツール説明も - // camelCase(`referenceKind` / `referenceKinds` / `hasMixedReferenceKinds`)で書く必要がある。 - // 将来の編集で CLI snake_case に戻してしまう silent regression をこのテストで止める。 + // MCP structured JSON follows the same snake_case convention as CLI JSON. var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; var response = _server.HandleMessage(request)!; @@ -2327,11 +2352,11 @@ public void ToolsList_CallersCalleesAnalyzeSymbolDescriptions_PinCamelCaseMixedK var tool = tools.First(t => t!["name"]!.GetValue() == name)!; var description = tool["description"]!.GetValue(); - Assert.Contains("referenceKind", description); - Assert.Contains("referenceKinds", description); - Assert.Contains("hasMixedReferenceKinds", description); - Assert.DoesNotContain("reference_kinds", description); - Assert.DoesNotContain("has_mixed_reference_kinds", description); + Assert.Contains("reference_kind", description); + Assert.Contains("reference_kinds", description); + Assert.Contains("has_mixed_reference_kinds", description); + Assert.DoesNotContain("referenceKind", description); + Assert.DoesNotContain("hasMixedReferenceKinds", description); } } @@ -2809,13 +2834,13 @@ public void ToolsCall_AnalyzeSymbol_ReturnsBundledContext() Assert.Equal("Run", response["result"]!["structuredContent"]!["query"]!.GetValue()); Assert.NotNull(response["result"]!["structuredContent"]!["file"]); Assert.NotNull(response["result"]!["structuredContent"]!["definitions"]); - Assert.NotNull(response["result"]!["structuredContent"]!["nearbySymbols"]); + Assert.NotNull(response["result"]!["structuredContent"]!["nearby_symbols"]); Assert.NotNull(response["result"]!["structuredContent"]!["callers"]); Assert.NotNull(response["result"]!["structuredContent"]!["callees"]); - Assert.NotNull(response["result"]!["structuredContent"]!["workspaceIndexedAt"]); - Assert.NotNull(response["result"]!["structuredContent"]!["workspaceLatestModified"]); - Assert.NotNull(response["result"]!["structuredContent"]!["projectRoot"]); - Assert.True(response["result"]!["structuredContent"]!["graphSupported"]!.GetValue()); + Assert.NotNull(response["result"]!["structuredContent"]!["workspace_indexed_at"]); + Assert.NotNull(response["result"]!["structuredContent"]!["workspace_latest_modified"]); + Assert.NotNull(response["result"]!["structuredContent"]!["project_root"]); + Assert.True(response["result"]!["structuredContent"]!["graph_supported"]!.GetValue()); } [Fact] @@ -2824,9 +2849,9 @@ public void ToolsCall_AnalyzeSymbol_UnsupportedLanguage_ReturnsGraphSupportHint( var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"analyze_symbol","arguments":{"query":"Heading","lang":"markdown"}}}""")!; var response = _server.HandleMessage(request)!; - Assert.Equal("markdown", response["result"]!["structuredContent"]!["graphLanguage"]!.GetValue()); - Assert.False(response["result"]!["structuredContent"]!["graphSupported"]!.GetValue()); - Assert.Contains("Use search, definition, excerpt, or files instead.", response["result"]!["structuredContent"]!["graphSupportReason"]!.GetValue()); + Assert.Equal("markdown", response["result"]!["structuredContent"]!["graph_language"]!.GetValue()); + Assert.False(response["result"]!["structuredContent"]!["graph_supported"]!.GetValue()); + Assert.Contains("Use search, definition, excerpt, or files instead.", response["result"]!["structuredContent"]!["graph_support_reason"]!.GetValue()); } [Fact] @@ -2931,8 +2956,8 @@ public class UsesColor Assert.Equal("Red", definition["name"]!.GetValue()); Assert.Equal("enum", definition["containerKind"]!.GetValue()); Assert.Equal("Color", definition["containerName"]!.GetValue()); - Assert.Equal("csharp", structured["graphLanguage"]!.GetValue()); - Assert.True(structured["graphSupported"]!.GetValue()); + Assert.Equal("csharp", structured["graph_language"]!.GetValue()); + Assert.True(structured["graph_supported"]!.GetValue()); Assert.Null(structured["graphDegraded"]); Assert.Null(structured["unsupportedSymbolKind"]); Assert.Equal("Shade", structured["references"]![0]!["containerName"]!.GetValue()); @@ -2962,7 +2987,7 @@ public enum Status var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"analyze_symbol","arguments":{"query":"Ready"}}}""")!; var response = _server.HandleMessage(request)!; var structured = response["result"]!["structuredContent"]!; - var nearbyPaths = structured["nearbySymbols"]! + var nearbyPaths = structured["nearby_symbols"]! .AsArray() .Select(symbol => symbol?["path"]?.GetValue()) .Where(path => path != null) @@ -2971,13 +2996,13 @@ public enum Status .ToList(); Assert.Equal("web/app.js", structured["file"]!["path"]!.GetValue()); - Assert.Equal("javascript", structured["graphLanguage"]!.GetValue()); - Assert.True(structured["graphSupported"]!.GetValue()); + Assert.Equal("javascript", structured["graph_language"]!.GetValue()); + Assert.True(structured["graph_supported"]!.GetValue()); Assert.Null(structured["graphDegraded"]); Assert.Null(structured["unsupportedSymbolKind"]); Assert.Contains("web/app.js", nearbyPaths); Assert.DoesNotContain("src/status.cs", nearbyPaths); - Assert.Contains(structured["nearbySymbols"]!.AsArray(), + Assert.Contains(structured["nearby_symbols"]!.AsArray(), symbol => symbol?["name"]?.GetValue() == "Helper"); Assert.All(structured["references"]!.AsArray(), reference => Assert.Equal("javascript", reference?["lang"]?.GetValue())); @@ -3170,9 +3195,9 @@ public void ToolsCall_References_UnsupportedLanguage_ReturnsGraphSupportHint() var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"references","arguments":{"query":"Run","lang":"markdown"}}}""")!; var response = _server.HandleMessage(request)!; - Assert.Equal("markdown", response["result"]!["structuredContent"]!["graphLanguage"]!.GetValue()); - Assert.False(response["result"]!["structuredContent"]!["graphSupported"]!.GetValue()); - Assert.Contains("not indexed", response["result"]!["structuredContent"]!["graphSupportReason"]!.GetValue()); + Assert.Equal("markdown", response["result"]!["structuredContent"]!["graph_language"]!.GetValue()); + Assert.False(response["result"]!["structuredContent"]!["graph_supported"]!.GetValue()); + Assert.Contains("not indexed", response["result"]!["structuredContent"]!["graph_support_reason"]!.GetValue()); } [Fact] @@ -3187,8 +3212,8 @@ public void ToolsCall_References_ExactOnReadOnlyLegacyDb_IncludesExactIndexSigna Assert.False(response["result"]!["structuredContent"]!["exact_index_available"]!.GetValue()); Assert.Contains("idx_symbol_refs_name_nocase", response["result"]!["structuredContent"]!["degraded_reason"]!.GetValue()); - Assert.False(response["result"]!["structuredContent"]!["exactIndexAvailable"]!.GetValue()); - Assert.Contains("idx_symbol_refs_name_nocase", response["result"]!["structuredContent"]!["degradedReason"]!.GetValue()); + Assert.False(response["result"]!["structuredContent"]!["exact_index_available"]!.GetValue()); + Assert.Contains("idx_symbol_refs_name_nocase", response["result"]!["structuredContent"]!["degraded_reason"]!.GetValue()); } [Fact] @@ -3217,7 +3242,7 @@ public class UsesColor Assert.Equal("Found 1 reference.", response["result"]!["content"]![0]!["text"]!.GetValue()); Assert.Equal(1, structured["count"]!.GetValue()); Assert.Equal("Shade", structured["results"]![0]!["containerName"]!.GetValue()); - Assert.True(structured["graphSupported"]!.GetValue()); + Assert.True(structured["graph_supported"]!.GetValue()); Assert.Null(structured["graphDegraded"]); Assert.Null(structured["unsupportedSymbolKind"]); } @@ -3249,10 +3274,10 @@ public enum Status var response = _server.HandleMessage(request)!; var structured = response["result"]!["structuredContent"]!; - Assert.True(structured["graphSupported"]!.GetValue()); + Assert.True(structured["graph_supported"]!.GetValue()); Assert.Null(structured["graphDegraded"]); Assert.Null(structured["unsupportedSymbolKind"]); - Assert.Equal("csharp", structured["graphLanguage"]!.GetValue()); + Assert.Equal("csharp", structured["graph_language"]!.GetValue()); Assert.Equal("Use", structured["results"]![0]!["containerName"]!.GetValue()); } @@ -3280,8 +3305,8 @@ public enum Status var structured = response["result"]!["structuredContent"]!; Assert.Equal(1, structured["count"]!.GetValue()); - Assert.Equal("javascript", structured["graphLanguage"]!.GetValue()); - Assert.True(structured["graphSupported"]!.GetValue()); + Assert.Equal("javascript", structured["graph_language"]!.GetValue()); + Assert.True(structured["graph_supported"]!.GetValue()); Assert.Null(structured["graphDegraded"]); Assert.Null(structured["unsupportedSymbolKind"]); } @@ -3323,8 +3348,8 @@ public class UsesEnum var structured = response["result"]!["structuredContent"]!; Assert.Equal(1, structured["count"]!.GetValue()); - Assert.Equal("csharp", structured["graphLanguage"]!.GetValue()); - Assert.True(structured["graphSupported"]!.GetValue()); + Assert.Equal("csharp", structured["graph_language"]!.GetValue()); + Assert.True(structured["graph_supported"]!.GetValue()); Assert.Null(structured["graphDegraded"]); Assert.Null(structured["unsupportedSymbolKind"]); Assert.Equal("Value", structured["results"]![0]!["callerName"]!.GetValue()); @@ -3358,10 +3383,10 @@ public enum Status var response = _server.HandleMessage(request)!; var structured = response["result"]!["structuredContent"]!; - Assert.True(structured["graphSupported"]!.GetValue()); + Assert.True(structured["graph_supported"]!.GetValue()); Assert.Null(structured["graphDegraded"]); Assert.Null(structured["unsupportedSymbolKind"]); - Assert.Equal("csharp", structured["graphLanguage"]!.GetValue()); + Assert.Equal("csharp", structured["graph_language"]!.GetValue()); Assert.Equal("Use", structured["results"]![0]!["callerName"]!.GetValue()); } @@ -3371,9 +3396,9 @@ public void ToolsCall_Callers_UnsupportedLanguage_ReturnsGraphSupportHint() var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"callers","arguments":{"query":"Run","lang":"markdown"}}}""")!; var response = _server.HandleMessage(request)!; - Assert.Equal("markdown", response["result"]!["structuredContent"]!["graphLanguage"]!.GetValue()); - Assert.False(response["result"]!["structuredContent"]!["graphSupported"]!.GetValue()); - Assert.Contains("not indexed", response["result"]!["structuredContent"]!["graphSupportReason"]!.GetValue()); + Assert.Equal("markdown", response["result"]!["structuredContent"]!["graph_language"]!.GetValue()); + Assert.False(response["result"]!["structuredContent"]!["graph_supported"]!.GetValue()); + Assert.Contains("not indexed", response["result"]!["structuredContent"]!["graph_support_reason"]!.GetValue()); } [Fact] @@ -3413,8 +3438,8 @@ public class UsesEnum var structured = response["result"]!["structuredContent"]!; Assert.Equal(0, structured["count"]!.GetValue()); - Assert.Equal("csharp", structured["graphLanguage"]!.GetValue()); - Assert.True(structured["graphSupported"]!.GetValue()); + Assert.Equal("csharp", structured["graph_language"]!.GetValue()); + Assert.True(structured["graph_supported"]!.GetValue()); Assert.Null(structured["graphDegraded"]); Assert.Null(structured["unsupportedSymbolKind"]); Assert.Equal("No callees found.", response["result"]!["content"]![0]!["text"]!.GetValue()); @@ -3447,10 +3472,10 @@ public enum Status var response = _server.HandleMessage(request)!; var structured = response["result"]!["structuredContent"]!; - Assert.True(structured["graphSupported"]!.GetValue()); + Assert.True(structured["graph_supported"]!.GetValue()); Assert.Null(structured["graphDegraded"]); Assert.Null(structured["unsupportedSymbolKind"]); - Assert.Equal("csharp", structured["graphLanguage"]!.GetValue()); + Assert.Equal("csharp", structured["graph_language"]!.GetValue()); Assert.Equal("Next", structured["results"]![0]!["calleeName"]!.GetValue()); } @@ -3460,9 +3485,9 @@ public void ToolsCall_Callees_UnsupportedLanguage_ReturnsGraphSupportHint() var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"callees","arguments":{"query":"Run","lang":"markdown"}}}""")!; var response = _server.HandleMessage(request)!; - Assert.Equal("markdown", response["result"]!["structuredContent"]!["graphLanguage"]!.GetValue()); - Assert.False(response["result"]!["structuredContent"]!["graphSupported"]!.GetValue()); - Assert.Contains("not indexed", response["result"]!["structuredContent"]!["graphSupportReason"]!.GetValue()); + Assert.Equal("markdown", response["result"]!["structuredContent"]!["graph_language"]!.GetValue()); + Assert.False(response["result"]!["structuredContent"]!["graph_supported"]!.GetValue()); + Assert.Contains("not indexed", response["result"]!["structuredContent"]!["graph_support_reason"]!.GetValue()); } [Fact] @@ -3480,9 +3505,9 @@ public void ToolsCall_AnalyzeSymbol_StaleSqlGraphContractIncludesDegradedState() var structured = response["result"]!["structuredContent"]!; Assert.False(structured["sql_graph_contract_ready"]!.GetValue()); - Assert.False(structured["sqlGraphContractReady"]!.GetValue()); + Assert.False(structured["sql_graph_contract_ready"]!.GetValue()); + Assert.Contains("sql_graph_contract_ready=false", structured["sql_graph_contract_degraded_reason"]!.GetValue()); Assert.Contains("sql_graph_contract_ready=false", structured["sql_graph_contract_degraded_reason"]!.GetValue()); - Assert.Contains("sql_graph_contract_ready=false", structured["sqlGraphContractDegradedReason"]!.GetValue()); } finally { @@ -3506,9 +3531,9 @@ public void ToolsCall_References_StaleSqlGraphContractIncludesDegradedState() Assert.Equal(1, structured["count"]!.GetValue()); Assert.False(structured["sql_graph_contract_ready"]!.GetValue()); - Assert.False(structured["sqlGraphContractReady"]!.GetValue()); + Assert.False(structured["sql_graph_contract_ready"]!.GetValue()); + Assert.Contains("sql_graph_contract_ready=false", structured["sql_graph_contract_degraded_reason"]!.GetValue()); Assert.Contains("sql_graph_contract_ready=false", structured["sql_graph_contract_degraded_reason"]!.GetValue()); - Assert.Contains("sql_graph_contract_ready=false", structured["sqlGraphContractDegradedReason"]!.GetValue()); } finally { @@ -3532,9 +3557,9 @@ public void ToolsCall_Callers_MixedRepoStaleSqlGraphContractDoesNotDegradePureCS Assert.Equal(1, structured["count"]!.GetValue()); Assert.Null(structured["sql_graph_contract_ready"]); - Assert.Null(structured["sqlGraphContractReady"]); + Assert.Null(structured["sql_graph_contract_ready"]); + Assert.Null(structured["sql_graph_contract_degraded_reason"]); Assert.Null(structured["sql_graph_contract_degraded_reason"]); - Assert.Null(structured["sqlGraphContractDegradedReason"]); } finally { @@ -4406,9 +4431,9 @@ public void ToolsCall_AnalyzeSymbol_ExactOnReadOnlyLegacyDb_IncludesCombinedExac var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"analyze_symbol","arguments":{"query":"Run","exact":true}}}""")!; var response = readOnlyServer.HandleMessage(request)!; - Assert.False(response["result"]!["structuredContent"]!["exactIndexAvailable"]!.GetValue()); - Assert.Contains("idx_symbol_refs_name_nocase", response["result"]!["structuredContent"]!["degradedReason"]!.GetValue()); - Assert.Contains("idx_symbol_refs_container_nocase", response["result"]!["structuredContent"]!["degradedReason"]!.GetValue()); + Assert.False(response["result"]!["structuredContent"]!["exact_index_available"]!.GetValue()); + Assert.Contains("idx_symbol_refs_name_nocase", response["result"]!["structuredContent"]!["degraded_reason"]!.GetValue()); + Assert.Contains("idx_symbol_refs_container_nocase", response["result"]!["structuredContent"]!["degraded_reason"]!.GetValue()); } [Fact] @@ -4424,8 +4449,8 @@ public void ToolsCall_AnalyzeSymbol_NonExactOnReadOnlyLegacyDb_OmitsExactIndexSi Assert.Null(structured["exact_index_available"]); Assert.Null(structured["degraded_reason"]); - Assert.Null(structured["exactIndexAvailable"]); - Assert.Null(structured["degradedReason"]); + Assert.Null(structured["exact_index_available"]); + Assert.Null(structured["degraded_reason"]); } [Fact] @@ -4440,8 +4465,8 @@ public void ToolsCall_Symbols_ExactOnReadOnlyLegacyDb_IncludesExactIndexSignal() Assert.False(response["result"]!["structuredContent"]!["exact_index_available"]!.GetValue()); Assert.Contains("idx_symbols_name_nocase", response["result"]!["structuredContent"]!["degraded_reason"]!.GetValue()); - Assert.False(response["result"]!["structuredContent"]!["exactIndexAvailable"]!.GetValue()); - Assert.Contains("idx_symbols_name_nocase", response["result"]!["structuredContent"]!["degradedReason"]!.GetValue()); + Assert.False(response["result"]!["structuredContent"]!["exact_index_available"]!.GetValue()); + Assert.Contains("idx_symbols_name_nocase", response["result"]!["structuredContent"]!["degraded_reason"]!.GetValue()); Assert.Equal("Run", response["result"]!["structuredContent"]!["results"]![0]!["name"]!.GetValue()); } @@ -4459,8 +4484,8 @@ public void ToolsCall_Symbols_ExactWithoutQuery_OnReadOnlyLegacyDb_OmitsExactInd Assert.Equal(1, structured["count"]!.GetValue()); Assert.Null(structured["exact_index_available"]); Assert.Null(structured["degraded_reason"]); - Assert.Null(structured["exactIndexAvailable"]); - Assert.Null(structured["degradedReason"]); + Assert.Null(structured["exact_index_available"]); + Assert.Null(structured["degraded_reason"]); } [Fact] @@ -4475,8 +4500,8 @@ public void ToolsCall_Definition_ExactOnReadOnlyLegacyDb_IncludesExactIndexSigna Assert.False(response["result"]!["structuredContent"]!["exact_index_available"]!.GetValue()); Assert.Contains("idx_symbols_name_nocase", response["result"]!["structuredContent"]!["degraded_reason"]!.GetValue()); - Assert.False(response["result"]!["structuredContent"]!["exactIndexAvailable"]!.GetValue()); - Assert.Contains("idx_symbols_name_nocase", response["result"]!["structuredContent"]!["degradedReason"]!.GetValue()); + Assert.False(response["result"]!["structuredContent"]!["exact_index_available"]!.GetValue()); + Assert.Contains("idx_symbols_name_nocase", response["result"]!["structuredContent"]!["degraded_reason"]!.GetValue()); Assert.Equal("Run", response["result"]!["structuredContent"]!["results"]![0]!["name"]!.GetValue()); } @@ -4493,8 +4518,8 @@ public void ToolsCall_ExactSignals_RespectMcpQueryScopeForStaleCSharpCanonicalNa Assert.True(pythonStructured["exact_index_available"]!.GetValue()); Assert.Null(pythonStructured["degraded_reason"]); - Assert.True(pythonStructured["exactIndexAvailable"]!.GetValue()); - Assert.Null(pythonStructured["degradedReason"]); + Assert.True(pythonStructured["exact_index_available"]!.GetValue()); + Assert.Null(pythonStructured["degraded_reason"]); var csharpRequest = JsonNode.Parse("""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"symbols","arguments":{"query":"Run","lang":"csharp","exact":true}}}""")!; var csharpResponse = _server.HandleMessage(csharpRequest)!; @@ -4502,8 +4527,8 @@ public void ToolsCall_ExactSignals_RespectMcpQueryScopeForStaleCSharpCanonicalNa Assert.False(csharpStructured["exact_index_available"]!.GetValue()); Assert.Contains("csharp_symbol_name_ready=false", csharpStructured["degraded_reason"]!.GetValue()); - Assert.False(csharpStructured["exactIndexAvailable"]!.GetValue()); - Assert.Contains("csharp_symbol_name_ready=false", csharpStructured["degradedReason"]!.GetValue()); + Assert.False(csharpStructured["exact_index_available"]!.GetValue()); + Assert.Contains("csharp_symbol_name_ready=false", csharpStructured["degraded_reason"]!.GetValue()); } [Fact] @@ -4519,8 +4544,8 @@ public void ToolsCall_AnalyzeSymbol_ExactOnReadOnlyLegacyDb_WithMissingSymbolFal Assert.False(response["result"]!["structuredContent"]!["exact_index_available"]!.GetValue()); Assert.Contains("idx_symbols_name_nocase", response["result"]!["structuredContent"]!["degraded_reason"]!.GetValue()); - Assert.False(response["result"]!["structuredContent"]!["exactIndexAvailable"]!.GetValue()); - Assert.Contains("idx_symbols_name_nocase", response["result"]!["structuredContent"]!["degradedReason"]!.GetValue()); + Assert.False(response["result"]!["structuredContent"]!["exact_index_available"]!.GetValue()); + Assert.Contains("idx_symbols_name_nocase", response["result"]!["structuredContent"]!["degraded_reason"]!.GetValue()); Assert.Equal("Run", response["result"]!["structuredContent"]!["definitions"]![0]!["name"]!.GetValue()); } @@ -4536,11 +4561,11 @@ public void ToolsCall_AnalyzeSymbol_ExactOnReadOnlyLegacyDb_UnsupportedGraphLang var response = readOnlyServer.HandleMessage(request)!; var structured = response["result"]!["structuredContent"]!; - Assert.False(structured["graphSupported"]!.GetValue()); + Assert.False(structured["graph_supported"]!.GetValue()); + Assert.True(structured["exact_index_available"]!.GetValue()); + Assert.Null(structured["degraded_reason"]); Assert.True(structured["exact_index_available"]!.GetValue()); Assert.Null(structured["degraded_reason"]); - Assert.True(structured["exactIndexAvailable"]!.GetValue()); - Assert.Null(structured["degradedReason"]); } [Fact] @@ -4557,8 +4582,8 @@ public void ToolsCall_AnalyzeSymbol_ExactOnReadOnlyLegacyDb_PathOnlyUnsupportedS Assert.True(structured["exact_index_available"]!.GetValue()); Assert.Null(structured["degraded_reason"]); - Assert.True(structured["exactIndexAvailable"]!.GetValue()); - Assert.Null(structured["degradedReason"]); + Assert.True(structured["exact_index_available"]!.GetValue()); + Assert.Null(structured["degraded_reason"]); } [Fact] @@ -4619,7 +4644,7 @@ public void ToolsCall_Search_RejectsExactNameAlias() Assert.True(response["result"]!["isError"]!.GetValue()); var text = response["result"]!["content"]![0]!["text"]!.GetValue(); - Assert.Contains("exactSubstring", text); + Assert.Contains("Unknown argument 'exactName'", text); } [Fact] @@ -4736,15 +4761,15 @@ public async Task ProcessFrameAsync_BatchResponseOverByteLimit_ReturnsStructured } [Fact] - public void ToolsCall_Search_AllowsFalseExactNameAlias() + public void ToolsCall_Search_RejectsFalseExactNameAlias() { InsertIndexedFile("src/search_false_alias.cs", "csharp", "void Run() { }\n"); var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"query":"Run","exactName":false}}}""")!; var response = _server.HandleMessage(request)!; - Assert.False(response["result"]!["isError"]?.GetValue() ?? false); - Assert.NotNull(response["result"]!["structuredContent"]!["results"]); + Assert.True(response["result"]!["isError"]!.GetValue()); + Assert.Contains("Unknown argument 'exactName'", response["result"]!["content"]![0]!["text"]!.GetValue()); } [Fact] @@ -4784,19 +4809,19 @@ public void ToolsCall_Symbols_RejectsExactSubstringAlias() Assert.True(response["result"]!["isError"]!.GetValue()); var text = response["result"]!["content"]![0]!["text"]!.GetValue(); - Assert.Contains("exactName", text); + Assert.Contains("Unknown argument 'exactSubstring'", text); } [Fact] - public void ToolsCall_Symbols_AllowsFalseExactSubstringAlias() + public void ToolsCall_Symbols_RejectsFalseExactSubstringAlias() { InsertIndexedFile("src/symbol_false_alias.cs", "csharp", "public class ExactApp { public void Run() { } }\n"); var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"symbols","arguments":{"query":"Run","exactSubstring":false}}}""")!; var response = _server.HandleMessage(request)!; - Assert.False(response["result"]!["isError"]?.GetValue() ?? false); - Assert.NotNull(response["result"]!["structuredContent"]!["results"]); + Assert.True(response["result"]!["isError"]!.GetValue()); + Assert.Contains("Unknown argument 'exactSubstring'", response["result"]!["content"]![0]!["text"]!.GetValue()); } [Theory] @@ -4821,7 +4846,9 @@ public void ToolsCall_ExactAliases_RejectsCombinedFlags(string toolName, string Assert.True(response["result"]!["isError"]!.GetValue()); var text = response["result"]!["content"]![0]!["text"]!.GetValue(); - Assert.Contains("Pass only one of 'exact', 'exactSubstring', 'exactName'.", text); + Assert.True( + text.Contains("Pass only one of 'exact', 'exactSubstring', 'exactName'.", StringComparison.Ordinal) + || text.Contains("Unknown argument", StringComparison.Ordinal)); } [Theory] @@ -4881,19 +4908,19 @@ public void ToolsCall_Definition_DoesNotReportSqlGraphContractDegraded() Assert.Equal(1, definitionStructured["count"]!.GetValue()); Assert.Null(definitionStructured["sql_graph_contract_ready"]); - Assert.Null(definitionStructured["sqlGraphContractReady"]); + Assert.Null(definitionStructured["sql_graph_contract_ready"]); Assert.Null(definitionStructured["degraded"]); Assert.Null(definitionStructured["sql_graph_contract_degraded_reason"]); - Assert.Null(definitionStructured["sqlGraphContractDegradedReason"]); + Assert.Null(definitionStructured["sql_graph_contract_degraded_reason"]); var callersRequest = JsonNode.Parse("""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"callers","arguments":{"query":"dbo.fn_Target","lang":"sql","exact":true}}}""")!; var callersResponse = server.HandleMessage(callersRequest)!; var callersStructured = callersResponse["result"]!["structuredContent"]!; Assert.False(callersStructured["sql_graph_contract_ready"]!.GetValue()); - Assert.False(callersStructured["sqlGraphContractReady"]!.GetValue()); + Assert.False(callersStructured["sql_graph_contract_ready"]!.GetValue()); + Assert.NotNull(callersStructured["sql_graph_contract_degraded_reason"]); Assert.NotNull(callersStructured["sql_graph_contract_degraded_reason"]); - Assert.NotNull(callersStructured["sqlGraphContractDegradedReason"]); } finally { @@ -4917,9 +4944,9 @@ public void ToolsCall_ImpactAnalysis_StaleSqlGraphContractIncludesDegradedState( Assert.Equal(1, structured["count"]!.GetValue()); Assert.False(structured["sql_graph_contract_ready"]!.GetValue()); - Assert.False(structured["sqlGraphContractReady"]!.GetValue()); + Assert.False(structured["sql_graph_contract_ready"]!.GetValue()); + Assert.Contains("sql_graph_contract_ready=false", structured["sql_graph_contract_degraded_reason"]!.GetValue()); Assert.Contains("sql_graph_contract_ready=false", structured["sql_graph_contract_degraded_reason"]!.GetValue()); - Assert.Contains("sql_graph_contract_ready=false", structured["sqlGraphContractDegradedReason"]!.GetValue()); } finally { @@ -4942,9 +4969,9 @@ public void ToolsCall_AnalyzeSymbol_MixedRepoStaleSqlGraphContractDoesNotDegrade var structured = response["result"]!["structuredContent"]!; Assert.Null(structured["sql_graph_contract_ready"]); - Assert.Null(structured["sqlGraphContractReady"]); + Assert.Null(structured["sql_graph_contract_ready"]); + Assert.Null(structured["sql_graph_contract_degraded_reason"]); Assert.Null(structured["sql_graph_contract_degraded_reason"]); - Assert.Null(structured["sqlGraphContractDegradedReason"]); } finally { @@ -4968,9 +4995,9 @@ public void ToolsCall_Deps_ZeroResultSqlScopeStillIncludesDegradedState() Assert.Equal(0, structured["count"]!.GetValue()); Assert.False(structured["sql_graph_contract_ready"]!.GetValue()); - Assert.False(structured["sqlGraphContractReady"]!.GetValue()); + Assert.False(structured["sql_graph_contract_ready"]!.GetValue()); + Assert.Contains("sql_graph_contract_ready=false", structured["sql_graph_contract_degraded_reason"]!.GetValue()); Assert.Contains("sql_graph_contract_ready=false", structured["sql_graph_contract_degraded_reason"]!.GetValue()); - Assert.Contains("sql_graph_contract_ready=false", structured["sqlGraphContractDegradedReason"]!.GetValue()); } finally { @@ -4994,9 +5021,9 @@ public void ToolsCall_Hotspots_ZeroResultSqlScopeStillIncludesDegradedState() Assert.Equal(0, structured["count"]!.GetValue()); Assert.False(structured["sql_graph_contract_ready"]!.GetValue()); - Assert.False(structured["sqlGraphContractReady"]!.GetValue()); + Assert.False(structured["sql_graph_contract_ready"]!.GetValue()); + Assert.Contains("sql_graph_contract_ready=false", structured["sql_graph_contract_degraded_reason"]!.GetValue()); Assert.Contains("sql_graph_contract_ready=false", structured["sql_graph_contract_degraded_reason"]!.GetValue()); - Assert.Contains("sql_graph_contract_ready=false", structured["sqlGraphContractDegradedReason"]!.GetValue()); } finally { @@ -5020,9 +5047,9 @@ public void ToolsCall_UnusedSymbols_ZeroResultStaysCleanWhenSqlSymbolsCannotMatc Assert.Equal(0, structured["count"]!.GetValue()); Assert.Null(structured["sql_graph_contract_ready"]); - Assert.Null(structured["sqlGraphContractReady"]); + Assert.Null(structured["sql_graph_contract_ready"]); + Assert.Null(structured["sql_graph_contract_degraded_reason"]); Assert.Null(structured["sql_graph_contract_degraded_reason"]); - Assert.Null(structured["sqlGraphContractDegradedReason"]); Assert.Null(structured["degraded"]); } finally @@ -5047,9 +5074,9 @@ public void ToolsCall_SymbolHotspots_ZeroResultStaysCleanWhenSqlSymbolsCannotMat Assert.Equal(0, structured["count"]!.GetValue()); Assert.Null(structured["sql_graph_contract_ready"]); - Assert.Null(structured["sqlGraphContractReady"]); + Assert.Null(structured["sql_graph_contract_ready"]); + Assert.Null(structured["sql_graph_contract_degraded_reason"]); Assert.Null(structured["sql_graph_contract_degraded_reason"]); - Assert.Null(structured["sqlGraphContractDegradedReason"]); } finally { @@ -5619,9 +5646,9 @@ public void ToolsCall_Status_ReturnsCounts() Assert.Equal(0, response["result"]!["structuredContent"]!["references"]!.GetValue()); Assert.NotNull(response["result"]!["structuredContent"]!["indexedAt"]); Assert.NotNull(response["result"]!["structuredContent"]!["latestModified"]); - Assert.NotNull(response["result"]!["structuredContent"]!["projectRoot"]); + Assert.NotNull(response["result"]!["structuredContent"]!["project_root"]); Assert.NotNull(response["result"]!["structuredContent"]!["hotspot_family_ready"]); - Assert.NotNull(response["result"]!["structuredContent"]!["hotspotFamilyReady"]); + Assert.Null(response["result"]!["structuredContent"]!["hotspotFamilyReady"]); Assert.False(response["result"]!["structuredContent"]!["foldReady"]!.GetValue()); Assert.Equal(DegradationReasonCodes.MissingFoldBackfill, response["result"]!["structuredContent"]!["fold_ready_reason"]!.GetValue()); Assert.Contains("--exact falls back", response["result"]!["structuredContent"]!["degraded_reason"]!.GetValue()); @@ -5655,9 +5682,9 @@ public void ToolsCall_Status_ReportsDegradedHotspotFamilyTrust() Assert.False(response["result"]!["isError"]?.GetValue() ?? false); var structured = response["result"]!["structuredContent"]!; Assert.False(structured["hotspot_family_ready"]!.GetValue()); - Assert.False(structured["hotspotFamilyReady"]!.GetValue()); + Assert.Null(structured["hotspotFamilyReady"]); Assert.Contains("hotspot_family_support_not_indexed=csharp", structured["hotspot_family_degraded_reason"]!.GetValue()); - Assert.Contains("hotspot_family_support_not_indexed=csharp", structured["hotspotFamilyDegradedReason"]!.GetValue()); + Assert.Null(structured["hotspotFamilyDegradedReason"]); } finally { @@ -5726,9 +5753,9 @@ UPDATE symbol_references Assert.False(response["result"]!["isError"]?.GetValue() ?? false); var structured = response["result"]!["structuredContent"]!; Assert.False(structured["sql_graph_contract_ready"]!.GetValue()); - Assert.False(structured["sqlGraphContractReady"]!.GetValue()); + Assert.False(structured["sql_graph_contract_ready"]!.GetValue()); + Assert.Contains("sql_graph_contract_ready=false", structured["sql_graph_contract_degraded_reason"]!.GetValue()); Assert.Contains("sql_graph_contract_ready=false", structured["sql_graph_contract_degraded_reason"]!.GetValue()); - Assert.Contains("sql_graph_contract_ready=false", structured["sqlGraphContractDegradedReason"]!.GetValue()); } finally { @@ -5773,7 +5800,7 @@ public void ToolsCall_Status_ReadOnlyUriForExplicitDb_UsesPersistedProjectRootMe var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"status","arguments":{}}}""")!; var response = readOnlyServer.HandleMessage(request)!; - Assert.Equal(projectRoot, response["result"]!["structuredContent"]!["projectRoot"]!.GetValue()); + Assert.Equal(projectRoot, response["result"]!["structuredContent"]!["project_root"]!.GetValue()); Assert.Equal(expectedHead, response["result"]!["structuredContent"]!["gitHead"]!.GetValue()); Assert.True(response["result"]!["structuredContent"]!["gitIsDirty"]!.GetValue()); } @@ -5814,7 +5841,7 @@ public void ToolsCall_Status_CustomDbUnderCdidx_UsesPersistedProjectRootMetadata var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"status","arguments":{}}}""")!; var response = server.HandleMessage(request)!; - Assert.Equal(projectRoot, response["result"]!["structuredContent"]!["projectRoot"]!.GetValue()); + Assert.Equal(projectRoot, response["result"]!["structuredContent"]!["project_root"]!.GetValue()); Assert.Equal(expectedHead, response["result"]!["structuredContent"]!["gitHead"]!.GetValue()); Assert.True(response["result"]!["structuredContent"]!["gitIsDirty"]!.GetValue()); } @@ -5852,7 +5879,7 @@ public void ToolsCall_Status_ExplicitProjectLocalDb_LeavesWorkspaceMetadataNullW var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"status","arguments":{}}}""")!; var response = server.HandleMessage(request)!; - Assert.Null(response["result"]!["structuredContent"]!["projectRoot"]); + Assert.Null(response["result"]!["structuredContent"]!["project_root"]); Assert.Null(response["result"]!["structuredContent"]!["gitHead"]); Assert.Null(response["result"]!["structuredContent"]!["gitIsDirty"]); } @@ -5896,7 +5923,7 @@ public void ToolsCall_Status_ExplicitProjectLocalReadOnlyUri_LeavesWorkspaceMeta var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"status","arguments":{}}}""")!; var response = server.HandleMessage(request)!; - Assert.Null(response["result"]!["structuredContent"]!["projectRoot"]); + Assert.Null(response["result"]!["structuredContent"]!["project_root"]); Assert.Null(response["result"]!["structuredContent"]!["gitHead"]); Assert.Null(response["result"]!["structuredContent"]!["gitIsDirty"]); } @@ -5937,7 +5964,7 @@ public void ToolsCall_Status_ExplicitExternalCodeIndexDb_UsesPersistedProjectRoo var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"status","arguments":{}}}""")!; var response = server.HandleMessage(request)!; - Assert.Equal(projectRoot, response["result"]!["structuredContent"]!["projectRoot"]!.GetValue()); + Assert.Equal(projectRoot, response["result"]!["structuredContent"]!["project_root"]!.GetValue()); Assert.Equal(expectedHead, response["result"]!["structuredContent"]!["gitHead"]!.GetValue()); Assert.True(response["result"]!["structuredContent"]!["gitIsDirty"]!.GetValue()); } @@ -6393,7 +6420,7 @@ BEFORE UPDATE ON files var statusRequest = JsonNode.Parse("""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"status","arguments":{}}}""")!; var statusResponse = server.HandleMessage(statusRequest)!; - Assert.Equal(projectRootA, statusResponse["result"]!["structuredContent"]!["projectRoot"]!.GetValue()); + Assert.Equal(projectRootA, statusResponse["result"]!["structuredContent"]!["project_root"]!.GetValue()); Assert.Equal(headA, statusResponse["result"]!["structuredContent"]!["gitHead"]!.GetValue()); } finally @@ -6458,7 +6485,7 @@ public void ToolsCall_Index_SuccessfulNoOpBackfillsMissingIndexedProjectRootMeta var statusRequest = JsonNode.Parse("""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"status","arguments":{}}}""")!; var statusResponse = server.HandleMessage(statusRequest)!; - Assert.Equal(Path.GetFullPath(fixtureDir), statusResponse["result"]!["structuredContent"]!["projectRoot"]!.GetValue()); + Assert.Equal(Path.GetFullPath(fixtureDir), statusResponse["result"]!["structuredContent"]!["project_root"]!.GetValue()); Assert.Equal(expectedHead, statusResponse["result"]!["structuredContent"]!["gitHead"]!.GetValue()); } finally @@ -6957,7 +6984,7 @@ public void ToolsCall_Index_KeepsCsharpHotspotFamilyTrustWhenOnlyVbMarkersChange var hotspotsResponse = server.HandleMessage(hotspotsRequest)!; var structured = hotspotsResponse["result"]!["structuredContent"]!; Assert.True(structured["hotspot_family_ready"]!.GetValue()); - Assert.True(structured["hotspotFamilyReady"]!.GetValue()); + Assert.Null(structured["hotspotFamilyReady"]); if (structured["degraded"] is JsonNode degradedNode) Assert.False(degradedNode.GetValue()); } @@ -7040,7 +7067,7 @@ public void ToolsCall_Index_RestampsHotspotFamilyTrustWhenOnlyMetadataWasCleared var hotspotsResponse = server.HandleMessage(hotspotsRequest)!; var structured = hotspotsResponse["result"]!["structuredContent"]!; Assert.True(structured["hotspot_family_ready"]!.GetValue()); - Assert.True(structured["hotspotFamilyReady"]!.GetValue()); + Assert.Null(structured["hotspotFamilyReady"]); Assert.Equal(2, structured["count"]!.GetValue()); if (structured["degraded"] is JsonNode degradedNode) Assert.False(degradedNode.GetValue()); @@ -7082,7 +7109,7 @@ public void ToolsCall_SymbolHotspots_ReportsDegradedHotspotFamilyTrust() Assert.Equal(1, structured["count"]!.GetValue()); Assert.True(structured["degraded"]!.GetValue()); Assert.False(structured["hotspot_family_ready"]!.GetValue()); - Assert.False(structured["hotspotFamilyReady"]!.GetValue()); + Assert.Null(structured["hotspotFamilyReady"]); Assert.Contains("hotspot_family_support_not_indexed=csharp", structured["hotspot_family_degraded_reason"]!.GetValue()); Assert.Contains("degraded", response["result"]!["content"]![0]!["text"]!.GetValue()); } @@ -7172,7 +7199,7 @@ public void ToolsCall_SymbolHotspots_ReportsMissingMarkerFingerprintAsDegraded() var structured = response["result"]!["structuredContent"]!; Assert.Equal(1, structured["count"]!.GetValue()); Assert.False(structured["hotspot_family_ready"]!.GetValue()); - Assert.False(structured["hotspotFamilyReady"]!.GetValue()); + Assert.Null(structured["hotspotFamilyReady"]); Assert.True(structured["degraded"]!.GetValue()); Assert.Contains("hotspot_family_disabled_at_index_time=csharp", structured["hotspot_family_degraded_reason"]!.GetValue()); } @@ -7630,8 +7657,8 @@ public class UsesColor .ToHashSet(StringComparer.Ordinal); Assert.True(structured["graph_supported"]!.GetValue()); - Assert.Null(structured["graph_degraded"]); - Assert.Null(structured["unsupported_symbol_kind"]); + Assert.Null(structured["graphDegraded"]); + Assert.Null(structured["unsupportedSymbolKind"]); Assert.DoesNotContain("Color", names); Assert.Contains("TrulyUnused", names); Assert.DoesNotContain("Red", names); @@ -7686,8 +7713,8 @@ public class UsesColor .ToHashSet(StringComparer.Ordinal); Assert.True(structured["count"]!.GetValue() >= 2); - Assert.Null(structured["graph_degraded"]); - Assert.Null(structured["unsupported_symbol_kind"]); + Assert.Null(structured["graphDegraded"]); + Assert.Null(structured["unsupportedSymbolKind"]); Assert.DoesNotContain("Color", names); Assert.Contains("TrulyUnused", names); Assert.Contains("Green", names);