diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 5544cb777a..1338d92dab 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -841,6 +841,7 @@ Process exit codes are coarse (`0` success, `1` usage, `2` not-found, `3` db, `4 - **Human-readable default** — All commands default to human-readable output. `--json` for AI/machine consumption. - **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 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. @@ -2378,6 +2379,7 @@ USER_GUIDEの[終了コード](USER_GUIDE.md#終了コード)セクションを - **言語考慮の参照抽出** — `references`、`callers`、`callees` は、正規表現ベースの call/reference 抽出が意味を持つ言語だけに対してインデックス化された参照テーブルで支える。未対応言語では、低信頼な疑似グラフ結果を返す代わりにテキスト検索へ戻る前提で設計する。**nested generic 呼び出し**: `new Dictionary>()` のような C#/Java のコンストラクタ呼び出しと、`Helper.DoWork>()` のような C# generic method call は、平坦な regex fast-path で `>>` を釣り合わせられなくても depth-aware fallback scanner で拾い直し、外側 target を参照テーブルへ残す。**JS/TS の no-paren constructor**: JavaScript / TypeScript の zero-argument constructor call で `()` を合法的に省略できる `new Foo;`、`new Date;`、`new Demo.Provider;`、`new Box;` も、専用の言語別経路で `instantiate` edge として出す。行末 `new Foo` に対する次行 `.bar()` / `[0]` continuation は suppress し、phantom な単独 instantiation にしない。**コンストラクタ連鎖呼び出し**: C# の `: this(...)` / `: base(...)` イニシャライザと、Java のコンストラクタ本体冒頭文 `this(...)` / `super(...)` は、汎用 call regex とは別に検出し、呼び先が実際のコンストラクタとなるように書き換える(`this` は外側の class/record、`base` / `super` は外側クラスのシグネチャから解析した基底型)。C# のクロス行イニシャライザは外側クラスではなく、そのコンストラクタに紐付ける。基底型の解析は generic 引数、record のプライマリコンストラクタ引数、`where` 制約、`global::` やドット付きの namespace 修飾を剥がす。Java の `super.method()` は通常のメソッド呼び出しのまま扱う。**型位置の依存エッジ**: C#/Java の継承リスト、宣言型、generic 制約、`throws`、`is` / `as` / `instanceof`、および C# XML doc の `cref` は `type_reference` 行として索引し、既定の `callers` / `callees` が見せる動的 call graph を汚さずに、`references` / `impact` から compile-time rename 依存を辿れるようにする。C# XML doc の `cref` 抽出は、実際に後続宣言へ結び付く XML-doc comment である `///` 行と delimited `/** ... */` block の両方を対象にしつつ、通常の `//` / `////` コメントや通常の block comment は phantom 依存として扱わない。また、同じ物理行でも closing `*/` より後ろに続く code / string の内容、doc comment と後続宣言の間へ割り込むトップレベル実行文、brace-free field/property initializer continuation、brace-free expression lambda、nested executable continuation、複数行 raw/verbatim string のうち行頭がたまたま `/**` で始まる内容は doc-comment slice の外として扱う。regex 自体は narrowed した doc-comment slice に対して走らせるが、`symbol_references.column` は元の物理ソース行位置に固定したまま保持する。C# の read path では、`using static` による constant-pattern suppress が `is` / `case` の前後の trivia を考慮してトークン単位で判定され、anchor が前行にある場合は anchor-aware な複数行コンテキストをインデックス済み行から再構成するため、`value is/*comment*/Red`、`value is\n Red or Blue`、`value is\n // comment\n Red`、`case\n // comment\n Point:`、長い `case` / `or` 連鎖、`case\tRed:` のような形でも phantom `type_reference` を漏らさない。qualified constant/member pattern は exact-name read path でも qualifier 起点で suppress するため、`case Color.Red or Color.Blue:` に対して無関係な `class Red {}` が suppress を打ち消さない。extractor 側の pending type-pattern carry も trivia-only 区切り行、standalone な continuation-line `not`、複数行 `case` head / logical continuation をまたいで維持されるため、comment-only 行や `not` だけの継続行で後続の本物の type head を落とさない。`case > 0:` や `case not > 0:` のような非型 `case` ラベルではその pending carry を armed にしないため、次行の call/identifier token が `type_reference` に混入しない。同名型の rescue も `file` 可視性を尊重し、file-local な型は同じ物理ファイル内の参照だけを救済する。基底クラスから見える protected/public/internal nested type は、基底型参照を active な型 alias / namespace alias 経由まで正規化し、さらに alias 展開後に constructed generic な基底型を再 canonicalize したうえで derived class の pattern head を救済する一方、implemented interface は inherited nested-type rescue に参加しない。さらに same-file `using Namespace;`、project-wide `global using Namespace;`、型 alias も同じ rescue 集合に入る。一方で extractor は file-local な情報だけでは同一 namespace の別ファイルにある実型を判定できないため、`value is Red` のような曖昧な unqualified `using static` head は DB に残し、pure constant-only case の抑止は workspace-aware な read path 側で行う。**SQL qualified-name alignment**: SQL の graph/dependency reader は、各 reference 行の source-line context、記録済み call 列位置、enclosing container から SQL 参照名を復元して定義と照合するため、qualified な `references` / `callers` / `impact` query は exact / non-exact を問わず sibling schema へ widen しない。source 側が genuinely unqualified な場合にだけ bare leaf fallback を許可するので、qualified call を含む `deps` / `unused` / `hotspots` も schema 単位で整合し、`EXEC dbo.fn_Target; EXEC sales.fn_Target;` のような同一行 multi-call も二重計上しない。列位置が記録されている row は、その列に qualified token が見つからなければ whole-line の別 qualified token へ昇格させないため、行末コメント・文字列リテラル・後続の別 call が先頭の unqualified edge を横取りすることもない。qualified な `callees` query でも caller query 自体が unqualified なとき以外は leaf fallback を無効化したため、`callees sales.Caller` が `dbo.Caller` へ広がらない。SQL extractor は qualified-name の `.` 前後空白も許容し、definition 系 reader は quoted qualified SQL name (`[dbo].[fn_X]` → `dbo.fn_X`) を正規化してから照合する。さらに exact SQL 定義照合は segment 数を保持し、SQL の exact graph leaf fallback は Unicode folded exact path を維持する。SQL CTE 本体内の source 行は raw `cte_body_reference` kind を使うため、`references --kind cte_body_reference` で anchor/recursive member 内部を outer query の table reference と区別できる。そのため、quoted single identifier の衝突や Unicode exact lookup の ASCII-only `NOCASE` 退行も防ぐ。exact な SQL の graph/dependency reader は解決済み segment 数も保持するため、`"sales.fn_Target"` のようなドット入り quoted single identifier が、本物の qualified name `sales.fn_Target` と exact `references` / `callers` / `impact` や集計系の `deps` / `unused` / `hotspots` で衝突しない。 - **構造化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 の言語サポート句** — 公開されるすべての 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/USER_GUIDE.md b/USER_GUIDE.md index d196035d29..7fbb5645ac 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1706,7 +1706,7 @@ The MCP `tools/list` descriptions include compact English/Japanese usage example | `impact_analysis` | Compute transitive callers of a symbol (inclusive `maxHops`: `maxHops: N` returns callers at hop 1..N — a chain A→B→C→D queried against D with `maxHops: 2` yields C at hop 1 and B at hop 2). The deprecated `maxDepth` alias is still accepted during the compatibility period and surfaces a warning. 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. Use `maxHops: 0` to resolve the symbol only, or rely on single-type fallback to heuristic file-level dependency hints and partial-definition hints; those file hints may include metadata edges. Pass `withPaths: true` to also receive a `paths` array per caller (shortest chains `[resolvedRoot, intermediate..., callerName]`; diamond convergence surfaces every route, capped per row with a `paths_truncated` overflow flag). | | `unused_symbols` | Find symbols defined but never referenced, with confidence buckets for dead-code triage | | `symbol_hotspots` | Find high-impact hotspots. `groupBy` supports `symbol`, `file`, and `statement`; SQL scopes default to statement grouping while non-SQL scopes default to symbol grouping. | -| `batch_query` | Execute multiple queries in a single call (MCP only, max 10). The response now includes a top-level `metadata` object with `total_elapsed_ms`, `success_count`, and `failure_count`, and every entry in `results` carries `elapsed_ms` plus a compact `args_summary` so callers can spot partial failures and slow inner queries without re-issuing them. | +| `batch_query` | Execute multiple queries in a single call (MCP only, max 10). The response includes a top-level `metadata` object with `submitted`, `executed`, `errors`, `total_elapsed_ms`, `success_count`, and `failure_count`; every entry in `results` carries `request_index`, `ok`, `elapsed_ms`, and compact `args_summary` fields so callers can correlate partial failures and slow inner queries without relying on positional guesses. | | `validate` | Report encoding issues (U+FFFD, BOM, null bytes, mixed/CR-only line endings, UTF-16 BOM detection, likely non-UTF8 encodings) | | `languages` | List all supported languages, file extensions, and capabilities | | `ping` | Lightweight connection check | @@ -3576,7 +3576,7 @@ OpenAI Codex CLI (`codex.json` または `~/.codex/config.json`): | `impact_analysis` | シンボルの推移的 caller を算出(`maxHops` は inclusive で、`maxHops: N` 指定時は hop 1〜N の caller を返す。例: A→B→C→D のチェーンで D を `maxHops: 2` 検索すると C(hop=1) と B(hop=2) が返る)。非推奨 alias の `maxDepth` は互換期間中も受け付け、使用時は warning を返す。symbol-level BFS は call graph 種別(`call`、`instantiate`、`subscribe`)のみを辿り、metadata-only edge(`attribute`、`annotation`、`type_reference`)を除外するため、metadata cycle で caller 件数が膨らまない。`maxHops: 0` で symbol 解決のみを行い、単一定義の型は heuristic な file-level dependency hint にフォールバックし、複数定義時はヒントも返す。この file hint は metadata edge を含み得る。`withPaths: true` を渡すと、各 caller に最短経路 `[resolvedRoot, 中間..., callerName]` の `paths` 配列が付き、ダイヤモンド収束時もすべての経路を返す(1 行あたりの保持上限を超えると `paths_truncated` で通知) | | `unused_symbols` | 定義されているが参照されていないシンボルを bucket 付きで検索(デッドコード検出向け) | | `symbol_hotspots` | 影響の大きい hotspot を検索。`groupBy` は `symbol` / `file` / `statement` を指定でき、SQL scope は statement grouping、非 SQL scope は symbol grouping が既定。 | -| `batch_query` | 複数クエリを1回で実行(MCP専用、最大10件)。レスポンスにはトップレベル `metadata`(`total_elapsed_ms` / `success_count` / `failure_count`)と各 `results` エントリの `elapsed_ms` / `args_summary` が含まれ、部分失敗や遅い内部クエリを再実行せず把握できます。 | +| `batch_query` | 複数クエリを1回で実行(MCP専用、最大10件)。レスポンスにはトップレベル `metadata`(`submitted` / `executed` / `errors` / `total_elapsed_ms` / `success_count` / `failure_count`)と各 `results` エントリの `request_index` / `ok` / `elapsed_ms` / `args_summary` が含まれ、位置だけに依存せず部分失敗や遅い内部クエリを把握できます。 | | `validate` | エンコーディング問題(U+FFFD、BOM、null バイト、改行混在 / CR-only 行末、UTF-16 BOM 検出、UTF-8 以外と推定されるエンコーディング)を報告 | | `languages` | 対応言語一覧を拡張子・機能付きで表示 | | `ping` | 軽量な接続確認 | diff --git a/changelog.d/unreleased/1838.fixed.md b/changelog.d/unreleased/1838.fixed.md new file mode 100644 index 0000000000..0c776e4e1d --- /dev/null +++ b/changelog.d/unreleased/1838.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 1838 +affected: + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **MCP `batch_query` results now include correlation fields (#1838)** — Each slot result carries `request_index` and `ok` so clients can match responses to requests without relying only on array position. + +## 日本語 + +- **MCP `batch_query` の結果に対応付けフィールドを追加しました (#1838)** — 各 slot 結果に `request_index` と `ok` を含め、クライアントが配列位置だけに依存せずリクエストとレスポンスを対応付けられるようにしました。 diff --git a/changelog.d/unreleased/1992.fixed.md b/changelog.d/unreleased/1992.fixed.md new file mode 100644 index 0000000000..ede5857c27 --- /dev/null +++ b/changelog.d/unreleased/1992.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 1992 +affected: + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **MCP `batch_query` now reports actual execution counts (#1992)** — Batch metadata now includes `submitted`, `executed`, and `errors`, and the summary says how many requests actually ran out of the submitted count. + +## 日本語 + +- **MCP `batch_query` が実際の実行件数を報告するようになりました (#1992)** — batch metadata に `submitted` / `executed` / `errors` を追加し、summary でも投入件数に対して実際に処理された件数を示すようにしました。 diff --git a/changelog.d/unreleased/1994.fixed.md b/changelog.d/unreleased/1994.fixed.md new file mode 100644 index 0000000000..147e711567 --- /dev/null +++ b/changelog.d/unreleased/1994.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 1994 +affected: + - src/CodeIndex/Mcp/McpToolHandlers.cs + - src/CodeIndex/Mcp/McpServer.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **MCP string-array validation now rejects mixed invalid entries (#1994)** — Mixed arrays such as `path` or `names` with null, blank, or non-string entries now fail with a structured validation error instead of silently dropping the bad values. + +## 日本語 + +- **MCP の string-array 検証が混在する不正要素を拒否するようになりました (#1994)** — `path` や `names` などに null、空白、非文字列が混ざった場合、不正値を暗黙に落とさず構造化 validation error として返します。 diff --git a/changelog.d/unreleased/2028.fixed.md b/changelog.d/unreleased/2028.fixed.md new file mode 100644 index 0000000000..c6f0c4c4be --- /dev/null +++ b/changelog.d/unreleased/2028.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 2028 +affected: + - src/CodeIndex/Mcp/McpToolHandlers.cs + - src/CodeIndex/Mcp/McpServer.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **MCP array filters now enforce size bounds (#2028)** — String-array filters are capped at 100 entries and 4096 characters per entry to prevent unbounded path/filter payloads from reaching query construction. + +## 日本語 + +- **MCP の配列 filter にサイズ上限を追加しました (#2028)** — string-array filter は 100 件、各要素 4096 文字を上限とし、無制限の path/filter payload が query construction に届かないようにしました。 diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 4b047619d5..d802ec328f 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -1572,54 +1572,66 @@ private JsonNode HandleToolsCall(JsonNode? id, JsonNode? callParams) JsonNode response; try { - // Per-(tool, caller) rate limiter check (#1560). Disabled by default; when an - // operator opts in via CDIDX_MCP_RATE_LIMIT_RPS we still keep the assignment-then- - // emit pattern so the rate-limit refusal lands in the audit log (#1562) instead of - // disappearing into a direct return. - // (tool, caller) ごとのレート制限 (#1560)。既定は無効。opt-in 時もアサインしてから - // 監査出力する構造を保ち、refusal が audit log (#1562) から消えないようにする。 - var decision = RateLimiter.TryAcquire(toolName, _caller); - if (!decision.Allowed) + if (ValidateCommonListArguments(args) is JsonObject listArgumentError) { - metricsError = "rate_limited"; - DeferFrameLog(BuildRateLimitedLog(toolName, _caller, decision.RetryAfterMs)); - response = CreateRateLimitedErrorResponse(id, toolName, _caller, decision.RetryAfterMs); + metricsError = "invalid_list_argument"; + response = CreateToolErrorResponse(id, listArgumentError["message"]!.GetValue(), + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "Send only non-empty string entries within the documented MCP array bounds.", + retrySafe: false, + extraData: listArgumentError); } else { - response = toolName switch + // Per-(tool, caller) rate limiter check (#1560). Disabled by default; when an + // operator opts in via CDIDX_MCP_RATE_LIMIT_RPS we still keep the assignment-then- + // emit pattern so the rate-limit refusal lands in the audit log (#1562) instead of + // disappearing into a direct return. + // (tool, caller) ごとのレート制限 (#1560)。既定は無効。opt-in 時もアサインしてから + // 監査出力する構造を保ち、refusal が audit log (#1562) から消えないようにする。 + var decision = RateLimiter.TryAcquire(toolName, _caller); + if (!decision.Allowed) + { + metricsError = "rate_limited"; + DeferFrameLog(BuildRateLimitedLog(toolName, _caller, decision.RetryAfterMs)); + response = CreateRateLimitedErrorResponse(id, toolName, _caller, decision.RetryAfterMs); + } + else + { + response = toolName switch - { - "search" => ExecuteSearch(id, args), - "definition" => ExecuteDefinition(id, args), - "references" => ExecuteReferences(id, args), - "callers" => ExecuteCallers(id, args), - "callees" => ExecuteCallees(id, args), - "symbols" => ExecuteSymbols(id, args), - "files" => ExecuteFiles(id, args), - "find_in_file" => ExecuteFindInFile(id, args), - "excerpt" => ExecuteExcerpt(id, args), - "map" => ExecuteMap(id, args), - "analyze_symbol" => ExecuteAnalyzeSymbol(id, args), - "status" => ExecuteStatus(id), - "outline" => ExecuteOutline(id, args), - "batch_query" => ExecuteBatchQuery(id, args), - "deps" => ExecuteDeps(id, args), - "impact_analysis" => ExecuteImpactAnalysis(id, args), - "languages" => ExecuteLanguages(id), - "validate" => ExecuteValidate(id, args), - "unused_symbols" => ExecuteUnusedSymbols(id, args), - "symbol_hotspots" => ExecuteSymbolHotspots(id, args), - "ping" => ExecutePing(id), - "index" => ExecuteIndex(id, args, progressToken), - "backfill_fold" => ExecuteBackfillFold(id, progressToken), - "suggest_improvement" => ExecuteSuggestImprovement(id, args), - _ => CreateErrorResponse(hasId: true, id: id, code: -32602, message: $"Unknown tool: {toolName}", - category: McpErrorEnvelope.CategoryToolUnknown, - suggestion: "Call tools/list to enumerate the available tool names for this server. Tool name match is case-sensitive.", - retrySafe: false, - extraData: new JsonObject { ["tool"] = toolName }), - }; + { + "search" => ExecuteSearch(id, args), + "definition" => ExecuteDefinition(id, args), + "references" => ExecuteReferences(id, args), + "callers" => ExecuteCallers(id, args), + "callees" => ExecuteCallees(id, args), + "symbols" => ExecuteSymbols(id, args), + "files" => ExecuteFiles(id, args), + "find_in_file" => ExecuteFindInFile(id, args), + "excerpt" => ExecuteExcerpt(id, args), + "map" => ExecuteMap(id, args), + "analyze_symbol" => ExecuteAnalyzeSymbol(id, args), + "status" => ExecuteStatus(id), + "outline" => ExecuteOutline(id, args), + "batch_query" => ExecuteBatchQuery(id, args), + "deps" => ExecuteDeps(id, args), + "impact_analysis" => ExecuteImpactAnalysis(id, args), + "languages" => ExecuteLanguages(id), + "validate" => ExecuteValidate(id, args), + "unused_symbols" => ExecuteUnusedSymbols(id, args), + "symbol_hotspots" => ExecuteSymbolHotspots(id, args), + "ping" => ExecutePing(id), + "index" => ExecuteIndex(id, args, progressToken), + "backfill_fold" => ExecuteBackfillFold(id, progressToken), + "suggest_improvement" => ExecuteSuggestImprovement(id, args), + _ => CreateErrorResponse(hasId: true, id: id, code: -32602, message: $"Unknown tool: {toolName}", + category: McpErrorEnvelope.CategoryToolUnknown, + suggestion: "Call tools/list to enumerate the available tool names for this server. Tool name match is case-sensitive.", + retrySafe: false, + extraData: new JsonObject { ["tool"] = toolName }), + }; + } } } catch (OperationCanceledException) when (_currentRequestToken.Value.IsCancellationRequested) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index b85a640754..1f78ec9f25 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -18,6 +18,8 @@ public partial class McpServer { private const int DefaultBatchQueryResponseByteLimit = MaxLineLength; private const string BatchQueryResponseByteLimitEnvVar = "CDIDX_MCP_BATCH_RESPONSE_MAX_BYTES"; + internal const int MaxMcpArrayFilterCount = 100; + internal const int MaxMcpArrayFilterStringLength = 4096; // --- Tool implementations / ツール実装 --- @@ -223,13 +225,98 @@ private static string BuildNonCallGraphKindRejectionMessage(string command, stri private static List ReadStringList(JsonNode? args, string propertyName) { return args?[propertyName] is JsonArray array - ? array.Select(node => node?.GetValue()) + ? array.Select(node => node is JsonValue value && value.TryGetValue(out var text) ? text : null) .Where(value => !string.IsNullOrWhiteSpace(value)) .Cast() .ToList() : []; } + private static JsonObject? ValidateCommonListArguments(JsonNode? args) + { + foreach (var propertyName in new[] { "path", "project", "excludePaths", "names" }) + { + if (ValidateStringListArgument(args, propertyName) is JsonObject error) + return error; + } + + return null; + } + + private static JsonObject? ValidateStringListArgument(JsonNode? args, string propertyName) + { + var node = args?[propertyName]; + if (node is null) + return null; + + if (node is JsonArray array) + { + if (array.Count > MaxMcpArrayFilterCount) + return new JsonObject + { + ["message"] = $"{propertyName} must contain at most {MaxMcpArrayFilterCount} entries.", + ["invalid_count"] = array.Count - MaxMcpArrayFilterCount, + }; + + var invalidCount = 0; + var invalidSamples = new JsonArray(); + for (var i = 0; i < array.Count; i++) + { + var element = array[i]; + if (element is not JsonValue value || !value.TryGetValue(out var text) || string.IsNullOrWhiteSpace(text)) + { + invalidCount++; + if (invalidSamples.Count < 3) + invalidSamples.Add($"[{i}]"); + continue; + } + + if (text.Length > MaxMcpArrayFilterStringLength) + { + invalidCount++; + if (invalidSamples.Count < 3) + invalidSamples.Add($"[{i}] length {text.Length}"); + } + } + + if (invalidCount > 0 && !(propertyName == "names" && invalidCount == array.Count)) + return new JsonObject + { + ["message"] = $"{propertyName} contains {invalidCount} invalid entr{(invalidCount == 1 ? "y" : "ies")}. Entries must be non-empty strings no longer than {MaxMcpArrayFilterStringLength} characters.", + ["invalid_count"] = invalidCount, + ["invalid_samples"] = invalidSamples, + }; + return null; + } + + if (node is JsonValue scalar && scalar.TryGetValue(out var scalarText)) + { + if (propertyName == "names") + return null; + if (propertyName == "path" && string.IsNullOrWhiteSpace(scalarText)) + return null; + if (string.IsNullOrWhiteSpace(scalarText)) + return new JsonObject + { + ["message"] = $"{propertyName} cannot be empty or whitespace-only.", + ["invalid_count"] = 1, + }; + if (scalarText.Length > MaxMcpArrayFilterStringLength) + return new JsonObject + { + ["message"] = $"{propertyName} must be no longer than {MaxMcpArrayFilterStringLength} characters.", + ["invalid_count"] = 1, + }; + return null; + } + + return new JsonObject + { + ["message"] = $"{propertyName} must be a string or an array of strings.", + ["invalid_count"] = 1, + }; + } + private static bool TryResolveSearchExactArgument(JsonNode? args, out bool exact, out string? error) { var legacyExact = args?["exact"]?.GetValue() ?? false; @@ -1455,25 +1542,27 @@ private JsonNode ExecuteBatchQuery(JsonNode? id, JsonNode? args) int failureCount = 0; var truncated = false; var responseByteLimit = GetBatchQueryResponseByteLimit(); - var estimatedResponseBytes = EstimateBatchResponseBytes(id, "Executed 0 queries.", successCount, failureCount, + var estimatedResponseBytes = EstimateBatchResponseBytes(id, "Executed 0 queries.", queries.Count, successCount, failureCount, responseByteLimit, resultsArray, truncated: false, truncatedQueries); - bool TryAppendResult(JsonObject entry, string? toolName, JsonNode? toolArgs, bool successfulSlot = false, bool failedSlot = false) + bool TryAppendResult(JsonObject entry, string? toolName, JsonNode? toolArgs, int requestIndex, bool successfulSlot = false, bool failedSlot = false) { var candidateResults = CloneJsonArray(resultsArray); candidateResults.Add(entry.DeepClone()); var candidateSuccessCount = successCount + (successfulSlot ? 1 : 0); var candidateFailureCount = failureCount + (failedSlot ? 1 : 0); + var candidateExecutedCount = candidateSuccessCount + candidateFailureCount; var candidateSummary = candidateFailureCount == 0 - ? $"Executed {candidateResults.Count} queries in 0 ms (all succeeded)." - : $"Executed {candidateResults.Count} queries in 0 ms ({candidateSuccessCount} succeeded, {candidateFailureCount} failed)."; - var candidateBytes = EstimateBatchResponseBytes(id, candidateSummary, candidateSuccessCount, candidateFailureCount, + ? $"Executed {candidateExecutedCount} of {queries.Count} queries in 0 ms (all succeeded)." + : $"Executed {candidateExecutedCount} of {queries.Count} queries in 0 ms ({candidateSuccessCount} succeeded, {candidateFailureCount} failed)."; + var candidateBytes = EstimateBatchResponseBytes(id, candidateSummary, queries.Count, candidateSuccessCount, candidateFailureCount, responseByteLimit, candidateResults, truncated: false, truncatedQueries); if (candidateBytes > responseByteLimit) { truncated = true; truncatedQueries.Add(new JsonObject { + ["request_index"] = requestIndex, ["tool"] = toolName, ["args_summary"] = BuildArgsSummary(toolArgs), ["reason"] = "response_byte_limit_exceeded", @@ -1486,13 +1575,15 @@ bool TryAppendResult(JsonObject entry, string? toolName, JsonNode? toolArgs, boo return true; } - void AppendSlotError(string? toolName, JsonNode? toolArgs, Stopwatch slotStopwatch, string errorMessage, + void AppendSlotError(int requestIndex, string? toolName, JsonNode? toolArgs, Stopwatch slotStopwatch, string errorMessage, int? code = null, string? category = null, string? suggestion = null, bool? retrySafe = null) { slotStopwatch.Stop(); var entry = new JsonObject { + ["request_index"] = requestIndex, ["tool"] = toolName, + ["ok"] = false, ["args_summary"] = BuildArgsSummary(toolArgs), ["elapsed_ms"] = slotStopwatch.ElapsedMilliseconds, ["error"] = errorMessage, @@ -1510,8 +1601,8 @@ void AppendSlotError(string? toolName, JsonNode? toolArgs, Stopwatch slotStopwat entry["suggestion"] = suggestion; if (retrySafe.HasValue) entry["retry_safe"] = retrySafe.Value; - if (TryAppendResult(entry, toolName, toolArgs, failedSlot: true)) - failureCount++; + TryAppendResult(entry, toolName, toolArgs, requestIndex, failedSlot: true); + failureCount++; } // Rate-limited slot error variant. Mirrors the shape of `AppendSlotError` so existing @@ -1525,7 +1616,7 @@ void AppendSlotError(string? toolName, JsonNode? toolArgs, Stopwatch slotStopwat // 検出・バックオフを可能にする。外側の batch_query 自体もトークンを消費するため、 // N 個の内側呼び出しを含むスパムは batch_query バケットとツール別バケットの両方で // 上限が掛かる(#1560)。 - void AppendRateLimitedSlot(string? toolName, JsonNode? toolArgs, Stopwatch slotStopwatch, long retryAfterMs) + void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArgs, Stopwatch slotStopwatch, long retryAfterMs) { slotStopwatch.Stop(); // #1581: emit the canonical envelope (`category`, `suggestion`, `retry_safe`) @@ -1536,7 +1627,9 @@ void AppendRateLimitedSlot(string? toolName, JsonNode? toolArgs, Stopwatch slotS // とスロット単位のレート制限エラーで判定形状を揃える。 var entry = new JsonObject { + ["request_index"] = requestIndex, ["tool"] = toolName, + ["ok"] = false, ["args_summary"] = BuildArgsSummary(toolArgs), ["elapsed_ms"] = slotStopwatch.ElapsedMilliseconds, ["error"] = $"Rate limit exceeded for tool '{toolName}' (retry after {retryAfterMs} ms).", @@ -1546,14 +1639,18 @@ void AppendRateLimitedSlot(string? toolName, JsonNode? toolArgs, Stopwatch slotS ["suggestion"] = $"Back off for at least {retryAfterMs} ms before retrying this tool.", ["retry_safe"] = true, }; - if (TryAppendResult(entry, toolName, toolArgs, failedSlot: true)) - failureCount++; + TryAppendResult(entry, toolName, toolArgs, requestIndex, failedSlot: true); + failureCount++; } - foreach (var q in queries) + for (var requestIndex = 0; requestIndex < queries.Count; requestIndex++) { - var toolName = q?["tool"]?.GetValue(); - var toolArgs = q?["arguments"]; + var q = queries[requestIndex]; + var queryObject = q as JsonObject; + var toolName = queryObject?["tool"] is JsonValue toolValue && toolValue.TryGetValue(out var parsedToolName) + ? parsedToolName + : null; + var toolArgs = queryObject?["arguments"]; var slotStopwatch = Stopwatch.StartNew(); if (truncated) @@ -1561,6 +1658,7 @@ void AppendRateLimitedSlot(string? toolName, JsonNode? toolArgs, Stopwatch slotS slotStopwatch.Stop(); truncatedQueries.Add(new JsonObject { + ["request_index"] = requestIndex, ["tool"] = toolName, ["args_summary"] = BuildArgsSummary(toolArgs), ["reason"] = "response_byte_limit_already_exceeded", @@ -1570,13 +1668,23 @@ void AppendRateLimitedSlot(string? toolName, JsonNode? toolArgs, Stopwatch slotS if (string.IsNullOrEmpty(toolName)) { - AppendSlotError(toolName, toolArgs, slotStopwatch, "Missing tool name", + var message = queryObject is null ? "Each query must be an object with a string tool name." : "Missing tool name"; + AppendSlotError(requestIndex, toolName, toolArgs, slotStopwatch, message, category: McpErrorEnvelope.CategoryMissingParameter, suggestion: "Each batch_query slot must include a string `tool` field.", retrySafe: false); continue; } + if (ValidateCommonListArguments(toolArgs) is JsonObject listArgumentError) + { + AppendSlotError(requestIndex, toolName, toolArgs, slotStopwatch, listArgumentError["message"]!.GetValue(), + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "Send only non-empty string entries within the documented MCP array bounds.", + retrySafe: false); + continue; + } + // Honor the per-deployment enablement gate inside batch_query too (#1561). Without // this, an operator who disabled a tool through `CDIDX_MCP_TOOLS_ALLOW` / // `CDIDX_MCP_TOOLS_DENY` could still reach it by smuggling the name into a batch @@ -1594,7 +1702,7 @@ void AppendRateLimitedSlot(string? toolName, JsonNode? toolArgs, Stopwatch slotS // 前にこのゲートを置く。 if (McpToolFilter.IsKnownTool(toolName) && !_toolFilter.IsEnabled(toolName)) { - AppendSlotError(toolName, toolArgs, slotStopwatch, $"Tool not enabled: {toolName}", code: -32601, + AppendSlotError(requestIndex, toolName, toolArgs, slotStopwatch, $"Tool not enabled: {toolName}", code: -32601, category: McpErrorEnvelope.CategoryToolDisabled, suggestion: "This tool is disabled on the server. Ask the operator to enable it or remove the slot.", retrySafe: false); @@ -1604,7 +1712,7 @@ void AppendRateLimitedSlot(string? toolName, JsonNode? toolArgs, Stopwatch slotS // Block write operations in batch / バッチ内では書き込み操作をブロック if (toolName == "index" || toolName == "backfill_fold" || toolName == "suggest_improvement") { - AppendSlotError(toolName, toolArgs, slotStopwatch, $"{toolName} is not allowed in batch_query (write operation)", + AppendSlotError(requestIndex, toolName, toolArgs, slotStopwatch, $"{toolName} is not allowed in batch_query (write operation)", category: McpErrorEnvelope.CategoryInvalidArgument, suggestion: "Call write tools (index / backfill_fold / suggest_improvement) directly via tools/call, not inside batch_query.", retrySafe: false); @@ -1619,7 +1727,7 @@ void AppendRateLimitedSlot(string? toolName, JsonNode? toolArgs, Stopwatch slotS // ネスト禁止の明示文に揃える(#1560)。 if (toolName == "batch_query") { - AppendSlotError(toolName, toolArgs, slotStopwatch, "batch_query cannot be nested inside batch_query.", + AppendSlotError(requestIndex, toolName, toolArgs, slotStopwatch, "batch_query cannot be nested inside batch_query.", category: McpErrorEnvelope.CategoryInvalidArgument, suggestion: "Flatten the nested batch_query into top-level slots.", retrySafe: false); @@ -1637,7 +1745,7 @@ void AppendRateLimitedSlot(string? toolName, JsonNode? toolArgs, Stopwatch slotS var slotDecision = RateLimiter.TryAcquire(toolName, _caller); if (!slotDecision.Allowed) { - AppendRateLimitedSlot(toolName, toolArgs, slotStopwatch, slotDecision.RetryAfterMs); + AppendRateLimitedSlot(requestIndex, toolName, toolArgs, slotStopwatch, slotDecision.RetryAfterMs); continue; } @@ -1671,7 +1779,7 @@ void AppendRateLimitedSlot(string? toolName, JsonNode? toolArgs, Stopwatch slotS if (response == null) { - AppendSlotError(toolName, toolArgs, slotStopwatch, $"Unknown tool: {toolName}", + AppendSlotError(requestIndex, toolName, toolArgs, slotStopwatch, $"Unknown tool: {toolName}", category: McpErrorEnvelope.CategoryToolUnknown, suggestion: "Call tools/list to see the tool catalog. Slot tool names are case-sensitive.", retrySafe: false); @@ -1697,7 +1805,7 @@ void AppendRateLimitedSlot(string? toolName, JsonNode? toolArgs, Stopwatch slotS bool? innerRetrySafe = null; if (innerStructured?["retry_safe"] is JsonValue rv && rv.TryGetValue(out var rb)) innerRetrySafe = rb; - AppendSlotError(toolName, toolArgs, slotStopwatch, errorText, + AppendSlotError(requestIndex, toolName, toolArgs, slotStopwatch, errorText, category: innerCategory, suggestion: innerSuggestion, retrySafe: innerRetrySafe); @@ -1708,13 +1816,15 @@ void AppendRateLimitedSlot(string? toolName, JsonNode? toolArgs, Stopwatch slotS var structured = response["result"]?["structuredContent"]; var entry = new JsonObject { + ["request_index"] = requestIndex, ["tool"] = toolName, + ["ok"] = true, ["args_summary"] = BuildArgsSummary(toolArgs), ["elapsed_ms"] = slotStopwatch.ElapsedMilliseconds, ["result"] = structured?.DeepClone(), }; - if (TryAppendResult(entry, toolName, toolArgs, successfulSlot: true)) - successCount++; + TryAppendResult(entry, toolName, toolArgs, requestIndex, successfulSlot: true); + successCount++; } catch (Exception ex) { @@ -1727,7 +1837,7 @@ void AppendRateLimitedSlot(string? toolName, JsonNode? toolArgs, Stopwatch slotS // envelope を batch_query スロットでも提供する。`ex.Message` の取り扱いは // #1530 サニタイザを通っていない既存挙動を維持し、追加メタデータのみを載せる。 var classification = McpErrorEnvelope.ClassifyException(ex); - AppendSlotError(toolName, toolArgs, slotStopwatch, ex.Message, + AppendSlotError(requestIndex, toolName, toolArgs, slotStopwatch, ex.Message, category: classification.Category, suggestion: classification.Suggestion, retrySafe: classification.RetrySafe); @@ -1741,6 +1851,9 @@ void AppendRateLimitedSlot(string? toolName, JsonNode? toolArgs, Stopwatch slotS ["count"] = resultsArray.Count, ["metadata"] = new JsonObject { + ["submitted"] = queries.Count, + ["executed"] = successCount + failureCount, + ["errors"] = failureCount, ["total_elapsed_ms"] = totalElapsedMs, ["success_count"] = successCount, ["failure_count"] = failureCount, @@ -1753,8 +1866,8 @@ void AppendRateLimitedSlot(string? toolName, JsonNode? toolArgs, Stopwatch slotS string BuildSummary() { var baseSummary = failureCount == 0 - ? $"Executed {resultsArray.Count} queries in {totalElapsedMs} ms (all succeeded)." - : $"Executed {resultsArray.Count} queries in {totalElapsedMs} ms ({successCount} succeeded, {failureCount} failed)."; + ? $"Executed {successCount + failureCount} of {queries.Count} queries in {totalElapsedMs} ms (all succeeded)." + : $"Executed {successCount + failureCount} of {queries.Count} queries in {totalElapsedMs} ms ({successCount} succeeded, {failureCount} failed)."; return truncated ? baseSummary + $" Response truncated at {responseByteLimit} bytes; split the batch or lower per-slot limits." : baseSummary; @@ -1778,12 +1891,9 @@ string BuildSummary() if (resultsArray.Count > 0) { var removed = resultsArray[resultsArray.Count - 1]; - if (removed?["error"] != null) - failureCount = Math.Max(0, failureCount - 1); - else - successCount = Math.Max(0, successCount - 1); truncatedQueries.Insert(0, new JsonObject { + ["request_index"] = removed?["request_index"]?.DeepClone(), ["tool"] = removed?["tool"]?.DeepClone(), ["args_summary"] = removed?["args_summary"]?.DeepClone(), ["reason"] = "final_response_byte_limit_exceeded", @@ -1814,7 +1924,7 @@ private static int GetBatchQueryResponseByteLimit() private int EstimateJsonUtf8Bytes(JsonNode node) => Encoding.UTF8.GetByteCount(node.ToJsonString(_jsonOptions)); - private int EstimateBatchResponseBytes(JsonNode? id, string summary, int successCount, int failureCount, + private int EstimateBatchResponseBytes(JsonNode? id, string summary, int submittedCount, int successCount, int failureCount, int responseByteLimit, JsonArray resultsArray, bool truncated, JsonArray truncatedQueries) { var payload = new JsonObject @@ -1822,6 +1932,9 @@ private int EstimateBatchResponseBytes(JsonNode? id, string summary, int success ["count"] = resultsArray.Count, ["metadata"] = new JsonObject { + ["submitted"] = submittedCount, + ["executed"] = successCount + failureCount, + ["errors"] = failureCount, ["total_elapsed_ms"] = 0, ["success_count"] = successCount, ["failure_count"] = failureCount, diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 1c1b40963b..15bfae8018 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -5505,11 +5505,19 @@ public void ToolsCall_BatchQuery_ExecutesMultipleQueries() var response = _server.HandleMessage(request)!; var text = response["result"]!["content"]![0]!["text"]!.GetValue(); - Assert.Contains("Executed 2 queries", text); + Assert.Contains("Executed 2 of 2 queries", text); var results = response["result"]!["structuredContent"]!["results"]!.AsArray(); Assert.Equal(2, results.Count); + Assert.Equal(0, results[0]!["request_index"]!.GetValue()); + Assert.True(results[0]!["ok"]!.GetValue()); Assert.Equal("status", results[0]!["tool"]!.GetValue()); + Assert.Equal(1, results[1]!["request_index"]!.GetValue()); + Assert.True(results[1]!["ok"]!.GetValue()); Assert.Equal("files", results[1]!["tool"]!.GetValue()); + var metadata = response["result"]!["structuredContent"]!["metadata"]!; + Assert.Equal(2, metadata["submitted"]!.GetValue()); + Assert.Equal(2, metadata["executed"]!.GetValue()); + Assert.Equal(0, metadata["errors"]!.GetValue()); } [Fact] @@ -5584,19 +5592,88 @@ public void ToolsCall_BatchQuery_CountsFailuresInEnvelope_Issue1537() var metadata = structured["metadata"]!; Assert.Equal(1, metadata["success_count"]!.GetValue()); Assert.Equal(2, metadata["failure_count"]!.GetValue()); + Assert.Equal(3, metadata["submitted"]!.GetValue()); + Assert.Equal(3, metadata["executed"]!.GetValue()); + Assert.Equal(2, metadata["errors"]!.GetValue()); var results = structured["results"]!.AsArray(); Assert.Equal(3, results.Count); Assert.NotNull(results[0]!["elapsed_ms"]); + Assert.True(results[0]!["ok"]!.GetValue()); Assert.NotNull(results[1]!["elapsed_ms"]); + Assert.False(results[1]!["ok"]!.GetValue()); Assert.NotNull(results[2]!["elapsed_ms"]); + Assert.False(results[2]!["ok"]!.GetValue()); + Assert.Equal(1, results[1]!["request_index"]!.GetValue()); + Assert.Equal(2, results[2]!["request_index"]!.GetValue()); Assert.Contains("not allowed", results[1]!["error"]!.GetValue()); Assert.Contains("Unknown tool", results[2]!["error"]!.GetValue()); var text = response["result"]!["content"]![0]!["text"]!.GetValue(); + Assert.Contains("Executed 3 of 3 queries", text); Assert.Contains("1 succeeded, 2 failed", text); } + [Fact] + public void ToolsCall_BatchQuery_ReportsMalformedSlotsAndActualExecutionCounts_Issue1838_1992_1994() + { + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"batch_query","arguments":{"queries":[123,{"tool":"search","arguments":{"query":"App","path":["",null,42,"src"]}},{"tool":"ping"}]}}}""")!; + var response = _server.HandleMessage(request)!; + + var structured = response["result"]!["structuredContent"]!; + var metadata = structured["metadata"]!; + Assert.Equal(3, metadata["submitted"]!.GetValue()); + Assert.Equal(3, metadata["executed"]!.GetValue()); + Assert.Equal(2, metadata["errors"]!.GetValue()); + + var results = structured["results"]!.AsArray(); + Assert.Equal(3, results.Count); + Assert.Equal(0, results[0]!["request_index"]!.GetValue()); + Assert.False(results[0]!["ok"]!.GetValue()); + Assert.Contains("must be an object", results[0]!["error"]!.GetValue()); + Assert.Equal(1, results[1]!["request_index"]!.GetValue()); + Assert.False(results[1]!["ok"]!.GetValue()); + Assert.Contains("path contains 3 invalid entries", results[1]!["error"]!.GetValue()); + Assert.Equal(2, results[2]!["request_index"]!.GetValue()); + Assert.True(results[2]!["ok"]!.GetValue()); + + var text = response["result"]!["content"]![0]!["text"]!.GetValue(); + Assert.Contains("Executed 3 of 3 queries", text); + Assert.Contains("1 succeeded, 2 failed", text); + } + + [Fact] + public void ToolsCall_RejectsOversizedPathArrays_Issue2028() + { + var paths = new JsonArray(); + for (var i = 0; i < McpServer.MaxMcpArrayFilterCount + 1; i++) + paths.Add($"src/{i}.cs"); + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "search", + ["arguments"] = new JsonObject + { + ["query"] = "App", + ["path"] = paths, + }, + }, + }; + + var response = _server.HandleMessage(request)!; + + Assert.True(response["result"]!["isError"]!.GetValue()); + var text = response["result"]!["content"]![0]!["text"]!.GetValue(); + Assert.Contains("path must contain at most", text); + var structured = response["result"]!["structuredContent"]!; + Assert.Equal(McpErrorEnvelope.CategoryInvalidArgument, structured["category"]!.GetValue()); + Assert.Equal(1, structured["invalid_count"]!.GetValue()); + } + [Fact] public void ToolsCall_BatchQuery_TruncatesAggregateResponse_Issue1416() { @@ -5614,6 +5691,9 @@ public void ToolsCall_BatchQuery_TruncatesAggregateResponse_Issue1416() Assert.True(actualResponseBytes <= 700, $"Actual response was {actualResponseBytes} bytes."); Assert.True(structured["metadata"]!["estimated_response_bytes"]!.GetValue() <= 700); Assert.Equal(700, structured["metadata"]!["response_byte_limit"]!.GetValue()); + Assert.Equal(2, structured["metadata"]!["submitted"]!.GetValue()); + Assert.Equal(2, structured["metadata"]!["executed"]!.GetValue()); + Assert.Equal(0, structured["metadata"]!["errors"]!.GetValue()); var truncatedQueries = structured["truncated_queries"]!.AsArray(); Assert.NotEmpty(truncatedQueries);