diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 4c49a86911..8752a2bd35 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2104,7 +2104,7 @@ The MCP `tools/list` response includes an `examples` array for every registered | `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 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. | +| `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`, optional client `slot_id`, `ok`, `elapsed_ms`, `summary`, 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 with origin/severity, 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 | @@ -2130,6 +2130,12 @@ Graph-oriented MCP tools such as `references`, `callers`, and `callees` also ret All MCP tools include `annotations` (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) so AI clients can auto-approve safe read-only queries without prompting the user. +`tools/list` also advertises argument compatibility metadata. Common path filters such as `excludePaths` accept either a scalar string or a string array, matching `path`; schemas expose `x-expectedType`, aliases through `x-aliases` / `x-aliasOf`, and deprecated aliases through `deprecated` plus `x-deprecationReason`. Validation errors echo `expected` and, where relevant, `alias_of` / `deprecated` so clients can recover without parsing prose. `definition` and `references` accept both `lsp_compatible` and the JSON-style `lspCompatible` alias. + +When an MCP tool clamps or ignores a supported argument, successful responses include human-readable `warnings` and machine-readable `argument_adjustments`. Each adjustment records `argument`, `action` (`clamped` or `ignored`), `requested`, `effective`, and cap fields when applicable, so clients can tell when `limit`, `offset`, `snippetLines`, `map.depth`, or `impact_analysis.maxHops` returned a narrower result than requested. + +`batch_query` advertises its slot cap as `queries.maxItems` in `tools/list`, and `status` exposes clear `mcp.limits.batch_query_max_queries`, `batch_query_response_bytes`, and `batch_query_max_response_bytes` fields distinct from JSON-RPC batch limits. Clients can pass `estimateOnly: true` to get `slot_estimates` and byte-budget metadata without executing slots, pass `maxResponseBytes` to request a lower per-call response budget, and add per-slot `id` or `slotId` values that are echoed as `slot_id`. When the aggregate response is truncated, `truncated_queries` preserves slot ids and `split_hint` returns `next_request_index`, `suggested_query_count`, and an opaque `resume_cursor` so clients can split the remaining original query array deterministically. + #### MCP error responses MCP JSON-RPC failures use the standard `error` object. Clients should route on @@ -2194,6 +2200,8 @@ For read-only deployments or sessions that only need a narrow tool surface, two When both are set, the allowlist wins. `tools/list` only advertises enabled tools, and the `initialize` instructions string no longer recommends tools the gate disabled. A top-level `tools/call` on a disabled known tool returns the structured JSON-RPC error `-32601 Tool not enabled: `. `batch_query` continues to succeed at the envelope, but each disabled-tool slot carries a `code: -32601` field alongside the `error` string so clients can branch on the code instead of substring-matching prose. Unknown names (typos) still surface as `-32602 Unknown tool`, so operator-disabled tools are distinguishable from missing tools. Names are compared case-insensitively. The default is **all tools enabled**, so existing deployments are unaffected unless an operator sets one of these variables. +Filter parsing also warns on `stderr` when an allow/deny variable is empty, contains empty CSV entries, or names unknown tools. Unknown names in `CDIDX_MCP_TOOLS_DENY` are ignored after the warning. `CDIDX_MCP_TOOLS_ALLOW` fails closed when it is explicitly set but contains no known tool names, so a typo-only allowlist exposes no tools instead of accidentally falling back to the default surface. Oversized filter values remain rejected with a warning. + #### MCP roots and sampling `cdidx mcp` advertises roots and sampling support during `initialize`. When the client supports roots, `index` refreshes `roots/list` and rejects paths outside the granted client roots. `suggest_improvement` uses `sampling/createMessage` to extract an optional one-line title and tag list before storing the raw suggestion. Sampling prompts are byte-bounded, long fields are clamped to one-line summaries, and `toolInvocationContext` is summarized without sending its raw content to the sampling client. Set `CDIDX_MCP_SAMPLING=0` (or `false` / `off`) to disable server-to-client sampling requests. @@ -4393,7 +4401,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`(`submitted` / `executed` / `errors` / `total_elapsed_ms` / `success_count` / `failure_count`)と各 `results` エントリの `request_index` / `ok` / `elapsed_ms` / `args_summary` が含まれ、位置だけに依存せず部分失敗や遅い内部クエリを把握できます。 | +| `batch_query` | 複数クエリを1回で実行(MCP専用、最大10件)。レスポンスにはトップレベル `metadata`(`submitted` / `executed` / `errors` / `total_elapsed_ms` / `success_count` / `failure_count`)と各 `results` エントリの `request_index`、任意の client `slot_id`、`ok`、`elapsed_ms`、`summary`、`args_summary` が含まれ、位置だけに依存せず部分失敗や遅い内部クエリを把握できます。 | | `validate` | エンコーディング問題(origin/severity 付き U+FFFD、BOM、null バイト、改行混在 / CR-only 行末、UTF-16 BOM 検出、UTF-8 以外と推定されるエンコーディング)を報告 | | `languages` | 対応言語一覧を拡張子・機能付きで表示。`--indexed-only` と `--capability graph|symbols|references` で現在の DB や機能別に絞り込み可能 | | `ping` | 軽量な接続確認 | @@ -4419,6 +4427,12 @@ cdidx backfill-fold 全 MCP ツールは `annotations`(`readOnlyHint`、`destructiveHint`、`idempotentHint`、`openWorldHint`)を含み、AIクライアントがユーザーへの確認なしに安全な読み取り専用クエリを自動承認できるようにしています。 +`tools/list` は引数互換メタデータも公開します。`excludePaths` などの共通 path filter は `path` と同じくスカラー文字列または文字列配列を受け付け、schema には `x-expectedType`、`x-aliases` / `x-aliasOf` による alias、`deprecated` と `x-deprecationReason` による非推奨 alias 情報が含まれます。検証エラーも `expected` と、該当する場合は `alias_of` / `deprecated` を返すため、クライアントは説明文を parse せず復旧できます。`definition` と `references` は `lsp_compatible` と JSON 風 alias の `lspCompatible` の両方を受け付けます。 + +MCP ツールが対応済み引数をクランプまたは無視した場合、成功レスポンスには人間向けの `warnings` と機械処理向けの `argument_adjustments` が含まれます。各 adjustment は `argument`、`action`(`clamped` または `ignored`)、`requested`、`effective`、必要に応じて cap 情報を持つため、`limit`、`offset`、`snippetLines`、`map.depth`、`impact_analysis.maxHops` が要求より狭い結果に調整されたかをクライアント側で判定できます。 + +`batch_query` は `tools/list` の `queries.maxItems` で slot 上限を広告し、`status` には JSON-RPC batch 上限と区別できる `mcp.limits.batch_query_max_queries`、`batch_query_response_bytes`、`batch_query_max_response_bytes` を返します。クライアントは `estimateOnly: true` で slot を実行せず `slot_estimates` と byte budget metadata を取得でき、`maxResponseBytes` で呼び出し単位の低い response budget を要求できます。各 slot の `id` または `slotId` は `slot_id` として echo されます。aggregate response が切り詰められた場合、`truncated_queries` は slot id を保持し、`split_hint` が `next_request_index`、`suggested_query_count`、opaque な `resume_cursor` を返すため、残りの元 query 配列を deterministic に分割して再実行できます。 + #### MCP エラー応答 MCP JSON-RPC の失敗は標準の `error` object を使います。クライアントは @@ -4483,6 +4497,8 @@ stdio の `cdidx mcp` を信頼度の低いチャネル(転送ソケット、 両方指定された場合は allowlist が優先されます。`tools/list` は有効ツールのみ広告し、`initialize` の instructions 文字列も無効化されたツールを推奨しなくなります。トップレベル `tools/call` で無効化された既知ツールを呼び出した場合は、構造化された JSON-RPC エラー `-32601 Tool not enabled: ` を返します。`batch_query` 自体は引き続きエンベロープとして成功しますが、無効化ツールの各 slot に `code: -32601` フィールドが `error` 文字列と並んで載るため、クライアントは prose の部分一致ではなく code で分岐できます。typo などサーバーに元から無い名前は引き続き `-32602 Unknown tool` を返すため、オペレータによる無効化と typo を区別できます。比較は大小文字無視。既定は **全ツール有効** なので、オペレータがこれらの変数を設定しない限り既存デプロイへの影響はありません。 +filter 解析では、allow / deny 変数が空、CSV 内に空 entry がある、または未知の tool 名を含む場合に `stderr` へ警告します。`CDIDX_MCP_TOOLS_DENY` の未知名は警告後に無視されます。`CDIDX_MCP_TOOLS_ALLOW` は明示的に設定されているのに既知 tool 名が 0 件の場合 fail closed となり、typo だけの allowlist が既定の全公開 surface に戻ることを防ぎます。過大な filter 値は従来通り warning 付きで拒否されます。 + #### MCP roots と sampling `cdidx mcp` は `initialize` で roots と sampling support を広告します。クライアントが roots をサポートする場合、`index` は `roots/list` を更新し、許可された client root の外にある path を拒否します。`suggest_improvement` は raw suggestion を保存する前に `sampling/createMessage` で任意の 1 行タイトルとタグ一覧を抽出します。sampling prompt は byte 上限内に収められ、長い field は 1 行 summary に切り詰められ、`toolInvocationContext` は raw 内容を sampling client に送らず summary 化されます。server-to-client sampling request を無効化するには `CDIDX_MCP_SAMPLING=0`(または `false` / `off`)を設定してください。 diff --git a/changelog.d/unreleased/3406.fixed.md b/changelog.d/unreleased/3406.fixed.md new file mode 100644 index 0000000000..3454525370 --- /dev/null +++ b/changelog.d/unreleased/3406.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3406 +affected: + - src/CodeIndex/Mcp/McpToolFilter.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **MCP tool filters now warn on invalid or unknown names (#3406)** — allow/deny filters emit bounded `stderr` warnings for empty entries and unknown tool names, and explicitly set allowlists fail closed when they contain no known tools. + +## 日本語 + +- **MCP tool filter が不正または未知の名前を警告するようになりました (#3406)** — allow / deny filter は空 entry と未知 tool 名を bounded な `stderr` warning として出力し、明示設定された allowlist に既知 tool が 0 件の場合は fail closed します。 diff --git a/changelog.d/unreleased/3436.fixed.md b/changelog.d/unreleased/3436.fixed.md new file mode 100644 index 0000000000..a4669d394c --- /dev/null +++ b/changelog.d/unreleased/3436.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 3436 +affected: + - src/CodeIndex/Mcp/McpServer.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **MCP clamped arguments now report requested and effective values (#3436)** — successful MCP responses add `warnings` and `argument_adjustments` when supported arguments such as `limit`, `offset`, `snippetLines`, `map.depth`, or `impact_analysis.maxHops` are clamped or ignored. + +## 日本語 + +- **MCP のクランプ済み引数が requested/effective を返すようになりました (#3436)** — `limit`、`offset`、`snippetLines`、`map.depth`、`impact_analysis.maxHops` などの対応済み引数がクランプまたは無視された場合、MCP の成功レスポンスに `warnings` と `argument_adjustments` が含まれるようになりました。 diff --git a/changelog.d/unreleased/3538.changed.md b/changelog.d/unreleased/3538.changed.md new file mode 100644 index 0000000000..f462923a5f --- /dev/null +++ b/changelog.d/unreleased/3538.changed.md @@ -0,0 +1,19 @@ +--- +category: changed +issues: + - 3538 +affected: + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpToolContractTests.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **MCP argument schemas now expose compatibility metadata (#3538)** — `tools/list` advertises expected JSON types, alias/deprecation metadata, scalar `excludePaths` filters, and the `lspCompatible` alias for `definition` / `references`. + +## 日本語 + +- **MCP 引数 schema が互換メタデータを公開するようになりました (#3538)** — `tools/list` は期待 JSON 型、alias / 非推奨メタデータ、スカラー `excludePaths` filter、`definition` / `references` 向けの `lspCompatible` alias を広告するようになりました。 diff --git a/changelog.d/unreleased/3539.changed.md b/changelog.d/unreleased/3539.changed.md new file mode 100644 index 0000000000..667d716ccd --- /dev/null +++ b/changelog.d/unreleased/3539.changed.md @@ -0,0 +1,18 @@ +--- +category: changed +issues: + - 3539 +affected: + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **MCP `batch_query` now advertises limits and resumable truncation hints (#3539)** — `tools/list` and `status` expose batch-query-specific caps, callers can use `estimateOnly`, `maxResponseBytes`, and slot ids, and truncated responses now include `split_hint` plus per-slot summaries for deterministic resume planning. + +## 日本語 + +- **MCP `batch_query` が上限と再開用 truncation hint を公開するようになりました (#3539)** — `tools/list` と `status` が batch_query 専用 cap を返し、呼び出し側は `estimateOnly`、`maxResponseBytes`、slot id を使えます。切り詰められたレスポンスには deterministic な再開計画向けに `split_hint` と slot 別 summary が含まれます。 diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 3f3629b697..ea8b21b4fc 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -174,6 +174,7 @@ public partial class McpServer : IDisposable internal const int MaxConfiguredResponseBytes = 64 * 1024 * 1024; internal const int MaxClientResponseJsonBytes = 1 * 1024 * 1024; internal const int MaxMcpPaginationOffset = 10_000; + internal const int MaxMcpMapDepth = 32; internal const double MinKeepAliveIntervalSeconds = 1.0; internal const double MaxKeepAliveIntervalSeconds = 300.0; private const string MaxResponseBytesEnvVar = "CDIDX_MCP_RESPONSE_MAX_BYTES"; diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 31958f131e..929d4003a7 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -34,7 +34,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["rawQuery"] = new JsonObject { ["type"] = "boolean", ["description"] = "Use raw FTS5 syntax instead of literal-safe quoting: content:term, NEAR(a b, 5), OR, NOT, parenthesized groups, prefix*, and quoted phrases.", ["default"] = false }, ["cursor"] = new JsonObject { ["type"] = "string", ["description"] = "Optional pagination cursor returned as `next_cursor` by a previous search response with the same query and filters. Compare `result_stable_at` across pages to detect index drift." }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict glob-style path patterns. `*` and `?` are wildcards. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude glob-style path patterns. `*` and `?` are wildcards." }, + ["excludePaths"] = StringOrArraySchema("Exclude glob-style path patterns. `*` and `?` are wildcards."), ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, ["since"] = new JsonObject { ["type"] = "string", ["description"] = "Filter to files modified since this ISO 8601 timestamp" }, @@ -67,8 +67,9 @@ private JsonNode HandleToolsList(JsonNode? id) ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = QueryCommandRunner.DefaultQueryLimit }, ["includeBody"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include body content when body ranges are available", ["default"] = false }, ["lsp_compatible"] = new JsonObject { ["type"] = "boolean", ["description"] = "Add file:// uri and LSP range fields to each result", ["default"] = false }, + ["lspCompatible"] = new JsonObject { ["type"] = "boolean", ["description"] = "Alias for `lsp_compatible` for JSON-style clients.", ["default"] = false }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict matches to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude any paths containing these texts" }, + ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, ["since"] = new JsonObject { ["type"] = "string", ["description"] = "Filter to symbols in files modified since this ISO 8601 timestamp" }, @@ -94,8 +95,9 @@ private JsonNode HandleToolsList(JsonNode? id) ["offset"] = new JsonObject { ["type"] = "integer", ["description"] = "Zero-based result offset for pagination; use `next_offset` from a truncated response.", ["default"] = 0, ["minimum"] = 0 }, ["maxLineWidth"] = new JsonObject { ["type"] = "integer", ["description"] = "Clamp very long single-line context payloads per result (default: 512; 0 disables clamping)", ["default"] = LineWidthFormatter.DefaultMaxLineWidth, ["minimum"] = 0, ["maximum"] = LineWidthFormatter.MaxAllowedLineWidth }, ["lsp_compatible"] = new JsonObject { ["type"] = "boolean", ["description"] = "Add file:// uri and LSP range fields to each result", ["default"] = false }, + ["lspCompatible"] = new JsonObject { ["type"] = "boolean", ["description"] = "Alias for `lsp_compatible` for JSON-style clients.", ["default"] = false }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict matches to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude any paths containing these texts" }, + ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, ["exactName"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preferred explicit name for exact referenced-symbol equality. Uses NFKC + Unicode CaseFold so `Run` no longer matches `RunAsync`.", ["default"] = false }, @@ -121,7 +123,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20). Responses include `truncated`, `more_available`, and `next_offset` when more rows exist.", ["default"] = QueryCommandRunner.DefaultQueryLimit }, ["offset"] = new JsonObject { ["type"] = "integer", ["description"] = "Zero-based result offset for pagination; use `next_offset` from a truncated response.", ["default"] = 0, ["minimum"] = 0 }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict matches to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude any paths containing these texts" }, + ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, ["exactName"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preferred explicit name for exact callee-name equality. Uses NFKC + Unicode CaseFold so `Run` no longer matches `RunAsync`.", ["default"] = false }, @@ -147,7 +149,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20). Responses include `truncated`, `more_available`, and `next_offset` when more rows exist.", ["default"] = QueryCommandRunner.DefaultQueryLimit }, ["offset"] = new JsonObject { ["type"] = "integer", ["description"] = "Zero-based result offset for pagination; use `next_offset` from a truncated response.", ["default"] = 0, ["minimum"] = 0 }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict matches to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude any paths containing these texts" }, + ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, ["exactName"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preferred explicit name for exact caller/container equality. Uses NFKC + Unicode CaseFold so `Run` no longer matches `RunAsync`.", ["default"] = false }, @@ -172,7 +174,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = QueryCommandRunner.DefaultQueryLimit }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict matches to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude any paths containing these texts" }, + ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, ["since"] = new JsonObject { ["type"] = "string", ["description"] = "Filter to symbols in files modified since this ISO 8601 timestamp" }, @@ -193,7 +195,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = QueryCommandRunner.DefaultQueryLimit }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Additional path filter text. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude any paths containing these texts" }, + ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, ["since"] = new JsonObject { ["type"] = "string", ["description"] = "Filter to files modified since this ISO 8601 timestamp" } @@ -234,7 +236,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Required file/path scope. Accepts a single string or an array; multiple values are OR'd together." }, ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max matching occurrences to return (default: 20)", ["default"] = QueryCommandRunner.DefaultQueryLimit }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, - ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude any paths containing these texts" }, + ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, ["before"] = new JsonObject { ["type"] = "integer", ["description"] = "Context lines before the match (default: 0, clamped to 1000)", ["default"] = 0, ["minimum"] = 0 }, @@ -260,10 +262,10 @@ private JsonNode HandleToolsList(JsonNode? id) ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max items per section (default: 10)", ["default"] = QueryCommandRunner.DefaultMapLimit }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict glob-style path patterns. `*` and `?` are wildcards. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude glob-style path patterns. `*` and `?` are wildcards." }, + ["excludePaths"] = StringOrArraySchema("Exclude glob-style path patterns. `*` and `?` are wildcards."), ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, ["sections"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "tree", "languages", "hotspots", "metrics" } }, ["description"] = "Only include selected response sections. Omit for the full backward-compatible map." }, - ["depth"] = new JsonObject { ["type"] = "integer", ["description"] = "Maximum module/tree depth to include; 0 keeps only root-level modules.", ["minimum"] = 0 } + ["depth"] = new JsonObject { ["type"] = "integer", ["description"] = $"Maximum module/tree depth to include; 0 keeps only root-level modules. Requests above {MaxMcpMapDepth} are clamped with an MCP warning.", ["minimum"] = 0, ["maximum"] = MaxMcpMapDepth } } }, ReadOnlyAnnotations()), @@ -281,7 +283,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["includeBody"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include body content in definitions when available", ["default"] = false }, ["maxLineWidth"] = new JsonObject { ["type"] = "integer", ["description"] = "Clamp bundled reference context lines so single-line files stay bounded (default: 512; 0 disables clamping)", ["default"] = LineWidthFormatter.DefaultMaxLineWidth, ["minimum"] = 0, ["maximum"] = LineWidthFormatter.MaxAllowedLineWidth }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude any paths containing these texts" }, + ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, ["exactName"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preferred explicit name for exact bundle symbol-name equality. Propagates through definitions, references, callers, and callees so `Run` no longer pulls in `RunAsync` / `RunImpact`.", ["default"] = false }, @@ -304,7 +306,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max total callers or heuristic file-level dependency hints to return (default: 50). Check `truncated` when the limit is reached; `truncated_reason` distinguishes `user_limit` (raise `limit` to get more) from `safety_cap` (pathological graph, raising `limit` will not help).", ["default"] = QueryCommandRunner.DefaultImpactLimit }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude any paths containing these texts" }, + ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, ["withPaths"] = new JsonObject { ["type"] = "boolean", ["description"] = "When true, each caller carries a `paths` array of shortest call chains [resolvedRoot, intermediate..., callerName]; diamond convergence surfaces every shortest route (per-row cap; `pathsTruncated` flag indicates overflow).", ["default"] = false }, @@ -346,7 +348,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max edges (default: 50)", ["default"] = QueryCommandRunner.DefaultImpactLimit }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Restrict source files to glob-style path patterns. `*` and `?` are wildcards. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude glob-style path patterns. `*` and `?` are wildcards." }, + ["excludePaths"] = StringOrArraySchema("Exclude glob-style path patterns. `*` and `?` are wildcards."), ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude test files", ["default"] = false }, ["reverse"] = new JsonObject { ["type"] = "boolean", ["description"] = "Reverse lookup: show files that depend ON the matched path", ["default"] = false }, ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "edgelist", "json-graph" }, ["description"] = "Structured response format. `edgelist` preserves the existing edges array; `json-graph` returns nodes and edges.", ["default"] = "edgelist" }, @@ -373,7 +375,7 @@ private JsonNode HandleToolsList(JsonNode? id) { ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by issue kind (replacement_char, bom, null_byte, mixed_line_endings, mixed_line_endings_three_way, cr_only_line_endings, utf16_bom, non_utf8_likely, line_too_long)" }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Filter to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude any paths containing these texts" }, + ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false } } }, @@ -398,18 +400,24 @@ private JsonNode HandleToolsList(JsonNode? id) ["queries"] = new JsonObject { ["type"] = "array", - ["description"] = "Array of {tool, arguments} objects. Only read-only tools are allowed (not index or backfill_fold).", + ["description"] = $"Array of {{tool, arguments}} objects. Only read-only tools are allowed (not index or backfill_fold). Hard cap: {MaxBatchQuerySize} slots.", + ["minItems"] = 1, + ["maxItems"] = MaxBatchQuerySize, ["items"] = new JsonObject { ["type"] = "object", ["properties"] = new JsonObject { + ["id"] = new JsonObject { ["type"] = "string", ["description"] = "Optional client-supplied slot identifier echoed as slot_id." }, + ["slotId"] = new JsonObject { ["type"] = "string", ["description"] = "Optional client-supplied slot identifier echoed as slot_id." }, ["tool"] = new JsonObject { ["type"] = "string", ["description"] = "Tool name (e.g. search, definition, symbols)" }, ["arguments"] = new JsonObject { ["type"] = "object", ["description"] = "Tool arguments" } }, ["required"] = new JsonArray { "tool" } } - } + }, + ["maxResponseBytes"] = new JsonObject { ["type"] = "integer", ["description"] = "Optional per-call response byte budget for this batch_query response. Values above the server cap are clamped and reported in argument_adjustments.", ["minimum"] = 1, ["maximum"] = MaxBatchQueryResponseByteLimit }, + ["estimateOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return budget and slot estimate metadata without executing the slots.", ["default"] = false } }, ["required"] = new JsonArray { "queries" } }, @@ -458,7 +466,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = QueryCommandRunner.DefaultQueryLimit }, ["groupBy"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray("symbol", "file", "statement"), ["description"] = "Grouping unit. Defaults to symbol for non-SQL scopes and statement for SQL scopes." }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Restrict to glob-style path patterns. `*` and `?` are wildcards. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude glob-style path patterns. `*` and `?` are wildcards." }, + ["excludePaths"] = StringOrArraySchema("Exclude glob-style path patterns. `*` and `?` are wildcards."), ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude test files (default: false)", ["default"] = false } } }, @@ -484,7 +492,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["bucket"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray("likely_unused_private", "maybe_unused_nonpublic", "public_or_exported_no_refs", "reflection_or_config_suspect"), ["description"] = "Return only one unused-symbol bucket." }, ["minConfidence"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray("medium", "low"), ["description"] = "Return symbols at or above this confidence threshold." }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Restrict to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude paths containing any of these texts" }, + ["excludePaths"] = StringOrArraySchema("Exclude paths containing any of these texts"), ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude test files (default: false)", ["default"] = false } } }, @@ -544,6 +552,16 @@ private JsonNode HandleToolsList(JsonNode? id) return CreateSuccessResponse(id, result); } + private static JsonObject StringOrArraySchema(string description) => new() + { + ["oneOf"] = new JsonArray + { + new JsonObject { ["type"] = "string" }, + new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } }, + }, + ["description"] = description, + }; + private static void AddProjectScopeProperties(JsonArray tools) { var scopedTools = new HashSet(StringComparer.Ordinal) @@ -607,7 +625,11 @@ private static void AddCommonSchemaConstraints(JsonArray tools) continue; foreach (var (name, schema) in properties) + { ApplyCommonSchemaConstraint(toolName, name, schema); + if (schema is JsonObject obj) + ApplyCommonSchemaMetadata(toolName, name, obj); + } } } @@ -716,6 +738,45 @@ private static void ApplyCommonSchemaConstraint(string toolName, string name, Js } } + private static void ApplyCommonSchemaMetadata(string toolName, string name, JsonObject obj) + { + if (TryGetExpectedJsonType(toolName, name, out var expected)) + obj["x-expectedType"] = expected; + + switch (toolName, name) + { + case ("definition", "lsp_compatible"): + case ("references", "lsp_compatible"): + obj["x-aliases"] = new JsonArray { "lspCompatible" }; + break; + case ("definition", "lspCompatible"): + case ("references", "lspCompatible"): + obj["x-aliasOf"] = "lsp_compatible"; + break; + case ("search", "exact"): + MarkDeprecatedAlias(obj, "exactSubstring", "Use `exactSubstring` for search exact substring matching."); + break; + case ("definition", "exact"): + case ("references", "exact"): + case ("callers", "exact"): + case ("callees", "exact"): + case ("symbols", "exact"): + case ("analyze_symbol", "exact"): + MarkDeprecatedAlias(obj, "exactName", "Use `exactName` for exact symbol-name matching."); + break; + case ("impact_analysis", "maxDepth"): + MarkDeprecatedAlias(obj, "maxHops", "Use `maxHops`; `maxDepth` is retained for compatibility."); + break; + } + } + + private static void MarkDeprecatedAlias(JsonObject obj, string aliasOf, string reason) + { + obj["x-aliasOf"] = aliasOf; + obj["deprecated"] = true; + obj["x-deprecationReason"] = reason; + } + private static void AppendConstraintDescription(JsonObject obj, string sentence) { var description = obj["description"]?.GetValue(); diff --git a/src/CodeIndex/Mcp/McpToolFilter.cs b/src/CodeIndex/Mcp/McpToolFilter.cs index 45f99db232..7aa5ca318e 100644 --- a/src/CodeIndex/Mcp/McpToolFilter.cs +++ b/src/CodeIndex/Mcp/McpToolFilter.cs @@ -22,6 +22,7 @@ public sealed class McpToolFilter internal const string DenyEnvVarName = "CDIDX_MCP_TOOLS_DENY"; internal const int MaxToolFilterCsvLength = 2048; internal const int MaxToolFilterCsvEntries = 128; + internal const int MaxToolFilterUnknownNamesReported = 8; private readonly HashSet _enabled; @@ -90,19 +91,23 @@ internal static McpToolFilter Parse(string? allowValue, string? denyValue) if (allowInvalid) return new McpToolFilter(new HashSet(StringComparer.OrdinalIgnoreCase)); + WarnUnknownNames(AllowEnvVarName, allow); var filtered = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var name in KnownToolNames) { if (allow.Contains(name)) filtered.Add(name); } + if (filtered.Count == 0) + Console.Error.WriteLine($"Warning: {AllowEnvVarName} did not contain any known MCP tool names; failing closed with no tools enabled."); return new McpToolFilter(filtered); } var enabled = new HashSet(KnownToolNames, StringComparer.OrdinalIgnoreCase); - var deny = SplitCsv(denyValue, DenyEnvVarName, out _, out _); - if (deny.Count > 0) + var deny = SplitCsv(denyValue, DenyEnvVarName, out var denySpecified, out var denyInvalid); + if (denySpecified && !denyInvalid) { + WarnUnknownNames(DenyEnvVarName, deny); foreach (var name in deny) enabled.Remove(name); } @@ -128,27 +133,55 @@ public static bool IsKnownTool(string? toolName) => private static HashSet SplitCsv(string? value, string source, out bool specified, out bool invalid) { var set = new HashSet(StringComparer.OrdinalIgnoreCase); - specified = !string.IsNullOrWhiteSpace(value); + specified = value != null; invalid = false; + if (value == null) + return set; if (string.IsNullOrWhiteSpace(value)) + { + Console.Error.WriteLine($"Warning: {source} is empty; no MCP tool names were provided."); return set; + } if (!ValidateCsvBounds(source, value)) { invalid = true; return set; } + var emptyEntries = 0; foreach (var raw in value.Split(',')) { var trimmed = raw.Trim(); if (trimmed.Length == 0) + { + emptyEntries++; continue; + } set.Add(trimmed); } + if (emptyEntries > 0) + Console.Error.WriteLine($"Warning: {source} ignored {emptyEntries} empty comma-separated entr{(emptyEntries == 1 ? "y" : "ies")}."); return set; } + private static void WarnUnknownNames(string source, HashSet names) + { + var unknown = names + .Where(name => !IsKnownTool(name)) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (unknown.Length == 0) + return; + + var displayed = unknown.Take(MaxToolFilterUnknownNamesReported).ToArray(); + var suffix = unknown.Length > displayed.Length + ? $", ... ({unknown.Length - displayed.Length} more)" + : ""; + Console.Error.WriteLine( + $"Warning: {source} ignored {unknown.Length} unknown MCP tool name{(unknown.Length == 1 ? "" : "s")}: {string.Join(", ", displayed)}{suffix}."); + } + private static bool ValidateCsvBounds(string source, string value) { if (value.Length > MaxToolFilterCsvLength) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 409970d778..1320a029d5 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -21,6 +21,7 @@ public partial class McpServer { private const int DefaultBatchQueryResponseByteLimit = MaxLineByteLength; internal const int MaxBatchQueryResponseByteLimit = 10 * 1024 * 1024; + internal const int MaxBatchQuerySize = 10; private const int DefaultExcerptOutputByteLimit = MaxLineByteLength; private const string BatchQueryResponseByteLimitEnvVar = "CDIDX_MCP_BATCH_RESPONSE_MAX_BYTES"; internal const int MaxMcpArrayFilterCount = QueryCommandRunner.MaxQueryPathFilterCount; @@ -298,14 +299,105 @@ private static JsonObject BuildExcerptArgs(string path, int startLine, int endLi ["endLine"] = endLine, }; - /// - /// Clamp limit to a safe range to prevent resource exhaustion. - /// リソース枯渇を防ぐためlimitを安全な範囲にクランプ。 - /// - private static int ClampLimit(int limit) => Math.Clamp(limit, 1, MaxLimit); + private sealed class ArgumentAdjustmentCollector + { + private readonly JsonArray _warnings = []; + private readonly JsonArray _adjustments = []; + + public int Count => _adjustments.Count; + + public void AddClamped(string argument, int requested, int effective, int minimum, int maximum) + { + var message = $"{argument} was clamped from {requested} to {effective} (server cap is [{minimum}, {maximum}])."; + _warnings.Add(message); + _adjustments.Add(new JsonObject + { + ["argument"] = argument, + ["action"] = "clamped", + ["requested"] = requested, + ["effective"] = effective, + ["minimum"] = minimum, + ["maximum"] = maximum, + ["message"] = message, + }); + } + + public void AddIgnored(string argument, int requested, string reason) + { + var message = $"{argument} value {requested} was ignored: {reason}"; + _warnings.Add(message); + _adjustments.Add(new JsonObject + { + ["argument"] = argument, + ["action"] = "ignored", + ["requested"] = requested, + ["effective"] = null, + ["message"] = message, + }); + } + + public void AddWarning(string message) + { + _warnings.Add(message); + } + + public void ApplyTo(JsonObject payload) + { + if (_warnings.Count > 0) + { + var warnings = payload["warnings"] as JsonArray ?? []; + foreach (var warning in _warnings) + warnings.Add(warning?.DeepClone()); + payload["warnings"] = warnings; + } + if (_adjustments.Count > 0) + payload["argument_adjustments"] = _adjustments.DeepClone(); + } + } + + private static int ReadLimit(JsonNode? args, int defaultLimit, ArgumentAdjustmentCollector adjustments) + { + var requested = args?["limit"]?.GetValue(); + var effective = Math.Clamp(requested ?? defaultLimit, 1, MaxLimit); + if (requested.HasValue && requested.Value != effective) + adjustments.AddClamped("limit", requested.Value, effective, 1, MaxLimit); + return effective; + } - private static int ReadOffset(JsonNode? args) - => Math.Clamp(args?["offset"]?.GetValue() ?? 0, 0, MaxMcpPaginationOffset); + private static int ReadOffset(JsonNode? args, ArgumentAdjustmentCollector adjustments) + { + var requested = args?["offset"]?.GetValue(); + var effective = Math.Clamp(requested ?? 0, 0, MaxMcpPaginationOffset); + if (requested.HasValue && requested.Value != effective) + adjustments.AddClamped("offset", requested.Value, effective, 0, MaxMcpPaginationOffset); + return effective; + } + + private static int ReadSnippetLines(JsonNode? args, int defaultSnippetLines, ArgumentAdjustmentCollector adjustments) + { + var requested = args?["snippetLines"]?.GetValue(); + var effective = SearchSnippetFormatter.ClampSnippetLines(requested ?? defaultSnippetLines); + if (requested.HasValue && requested.Value != effective) + adjustments.AddClamped("snippetLines", requested.Value, effective, 1, SearchSnippetFormatter.MaxSnippetLines); + return effective; + } + + private static int? ReadMapDepth(JsonNode? args, ArgumentAdjustmentCollector adjustments) + { + var requested = args?["depth"]?.GetValue(); + if (!requested.HasValue) + return null; + if (requested.Value < 0) + { + adjustments.AddIgnored("depth", requested.Value, "depth must be greater than or equal to 0."); + return null; + } + + var effective = Math.Min(requested.Value, MaxMcpMapDepth); + if (effective != requested.Value) + adjustments.AddClamped("depth", requested.Value, effective, 0, MaxMcpMapDepth); + return effective; + } private static string ReadResponseFormat(JsonNode? args) => args?["format"]?.GetValue()?.Trim().ToLowerInvariant() ?? "full"; @@ -412,12 +504,19 @@ 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 is JsonValue value && value.TryGetValue(out var text) ? text : null) + var node = args?[propertyName]; + if (node is JsonArray array) + { + return array.Select(node => node is JsonValue value && value.TryGetValue(out var text) ? text : null) .Where(value => !string.IsNullOrWhiteSpace(value)) .Cast() - .ToList() - : []; + .ToList(); + } + + if (node is JsonValue scalar && scalar.TryGetValue(out var scalarText) && !string.IsNullOrWhiteSpace(scalarText)) + return [scalarText]; + + return []; } private JsonNode? TryReadSearchGuardFilters(JsonNode? id, JsonNode? args, out List filters) @@ -509,6 +608,7 @@ private static List ReadStringList(JsonNode? args, string propertyName) ["message"] = $"Tool '{toolName}' does not accept arguments.", ["tool"] = toolName, }, + toolName, obj.First().Key); foreach (var property in obj) @@ -521,6 +621,7 @@ private static List ReadStringList(JsonNode? args, string propertyName) ["message"] = $"Unknown argument '{McpBoundedText.ForDisplay(property.Key).Text}' for tool '{toolName}'.", ["tool"] = toolName, }, + toolName, property.Key); } @@ -550,6 +651,11 @@ private static List ReadStringList(JsonNode? args, string propertyName) && offset < 0) return CreateIntegerMinimumArgumentError(toolName, "offset", minimum: 0, actual: offset); + if (args["maxResponseBytes"] is JsonValue maxResponseBytesValue + && maxResponseBytesValue.TryGetValue(out var maxResponseBytes) + && maxResponseBytes <= 0) + return CreateIntegerMinimumArgumentError(toolName, "maxResponseBytes", minimum: 1, actual: maxResponseBytes); + return null; } @@ -591,11 +697,44 @@ private static List ReadStringList(JsonNode? args, string propertyName) return null; } - private static JsonObject AddUnknownArgumentData(JsonObject error, string argumentName) + private static JsonObject AddUnknownArgumentData(JsonObject error, string toolName, string argumentName) { var display = McpBoundedText.ForDisplay(argumentName); error["unknown_argument"] = display.Text; display.AddMetadata(error, "unknown_argument"); + return AddArgumentCompatibilityData(error, toolName, argumentName); + } + + private static JsonObject AddArgumentCompatibilityData(JsonObject error, string toolName, string argumentName) + { + switch (toolName, argumentName) + { + case ("definition", "lspCompatible"): + case ("references", "lspCompatible"): + error["alias_of"] = "lsp_compatible"; + break; + case ("search", "exact"): + error["alias_of"] = "exactSubstring"; + error["deprecated"] = true; + error["deprecation_reason"] = "Use `exactSubstring` for search exact substring matching."; + break; + case ("definition", "exact"): + case ("references", "exact"): + case ("callers", "exact"): + case ("callees", "exact"): + case ("symbols", "exact"): + case ("analyze_symbol", "exact"): + error["alias_of"] = "exactName"; + error["deprecated"] = true; + error["deprecation_reason"] = "Use `exactName` for exact symbol-name matching."; + break; + case ("impact_analysis", "maxDepth"): + error["alias_of"] = "maxHops"; + error["deprecated"] = true; + error["deprecation_reason"] = "Use `maxHops`; `maxDepth` is retained for compatibility."; + break; + } + return error; } @@ -606,7 +745,7 @@ private static JsonObject AddUnknownArgumentData(JsonObject error, string argume if (TryGetExpectedJsonType(toolName, property.Key, out var expected) && !MatchesExpectedJsonType(property.Value, expected)) { - return new JsonObject + return AddArgumentCompatibilityData(new JsonObject { ["message"] = $"Invalid type for argument '{property.Key}' on tool '{toolName}'. Expected {expected}.", ["tool"] = toolName, @@ -614,7 +753,7 @@ private static JsonObject AddUnknownArgumentData(JsonObject error, string argume ["expected"] = expected, ["actual"] = DescribeJsonType(property.Value), ["jsonrpc_invalid_params"] = true, - }; + }, toolName, property.Key); } } @@ -623,10 +762,16 @@ private static JsonObject AddUnknownArgumentData(JsonObject error, string argume private static bool TryGetExpectedJsonType(string toolName, string argumentName, out string expected) { - if (argumentName is "excludePaths" or "names" or "sections" or "files" or "commits" or "changedBetween") + if (argumentName is "names" or "sections" or "files" or "commits" or "changedBetween") { - expected = string.Empty; - return false; + expected = "array"; + return true; + } + + if (argumentName == "excludePaths") + { + expected = "string_or_array"; + return true; } if (argumentName == "path") @@ -640,11 +785,12 @@ private static bool TryGetExpectedJsonType(string toolName, string argumentName, "limit" or "offset" or "snippetLines" or "maxLineWidth" or "before" or "after" or "focusLine" or "focusColumn" or "focusLength" or "startLine" or "endLine" or "maxHops" or "maxDepth" or "depth" or "parallelism" or "maxFileBytes" or - "guardWindow" or "maxOutputBytes" => "integer", + "guardWindow" or "maxOutputBytes" or "maxResponseBytes" => "integer", "excludeTests" or "includeGenerated" or "rawQuery" or "noDedup" or "exactSubstring" or "exactName" or "exact" or "prefix" or "countOnly" or "includeBody" or "lsp_compatible" or + "lspCompatible" or "regex" or "withPaths" or "rebuild" or "dryRun" or "dry_run" or "force" or - "optimize" or "reverse" or "cycles" => "boolean", + "optimize" or "reverse" or "cycles" or "estimateOnly" => "boolean", "project" or "requireBefore" or "requireAfter" or "rejectBefore" or "rejectAfter" => "string_or_array", "query" or "lang" or "kind" or "format" or "rankBy" or "since" or "cursor" or "solution" or "symbol" or "groupBy" or "category" or "language" or @@ -706,8 +852,8 @@ private static string DescribeJsonType(JsonNode? node) private static IReadOnlySet GetAllowedToolArguments(string toolName) => toolName switch { "search" => new HashSet(StringComparer.Ordinal) { "query", "limit", "lang", "snippetLines", "maxLineWidth", "rawQuery", "cursor", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "noDedup", "exactSubstring", "exact", "prefix", "requireBefore", "requireAfter", "rejectBefore", "rejectAfter", "guardWindow", "countOnly", "format", "project", "solution" }, - "definition" => new HashSet(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "includeBody", "lsp_compatible", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "format", "project", "solution" }, - "references" => new HashSet(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "offset", "maxLineWidth", "lsp_compatible", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "format", "project", "solution" }, + "definition" => new HashSet(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "includeBody", "lsp_compatible", "lspCompatible", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "format", "project", "solution" }, + "references" => new HashSet(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "offset", "maxLineWidth", "lsp_compatible", "lspCompatible", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "format", "project", "solution" }, "callers" or "callees" => new HashSet(StringComparer.Ordinal) { "query", "kind", "rankBy", "lang", "limit", "offset", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "format", "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", "project", "solution" }, @@ -716,7 +862,7 @@ private static string DescribeJsonType(JsonNode? node) "map" => new HashSet(StringComparer.Ordinal) { "limit", "lang", "path", "excludePaths", "excludeTests", "sections", "depth", "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" }, - "batch_query" => new HashSet(StringComparer.Ordinal) { "queries" }, + "batch_query" => new HashSet(StringComparer.Ordinal) { "queries", "maxResponseBytes", "estimateOnly" }, "deps" => new HashSet(StringComparer.Ordinal) { "path", "reverse", "format", "cycles", "lang", "limit", "excludePaths", "excludeTests", "project", "solution" }, "impact_analysis" => new HashSet(StringComparer.Ordinal) { "query", "lang", "maxHops", "maxDepth", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "withPaths", "countOnly", "project", "solution" }, "validate" => new HashSet(StringComparer.Ordinal) { "kind", "path", "excludePaths", "excludeTests", "project", "solution" }, @@ -776,7 +922,7 @@ private static string DescribeJsonType(JsonNode? node) if (node is JsonValue scalar && scalar.TryGetValue(out var scalarText)) { - if (propertyName is "excludePaths" or "names" or "sections") + if (propertyName is "names" or "sections") return new JsonObject { ["message"] = $"{propertyName} must be an array of strings.", @@ -857,6 +1003,27 @@ private static bool TryResolveNameExactArgument(JsonNode? args, string toolName, return true; } + private static bool TryReadLspCompatibleArgument(JsonNode? args, out bool lspCompatible, out string? error) + { + var snakeNode = args?["lsp_compatible"]; + var camelNode = args?["lspCompatible"]; + var snakeProvided = snakeNode is not null; + var camelProvided = camelNode is not null; + var snakeValue = snakeNode?.GetValue() ?? false; + var camelValue = camelNode?.GetValue() ?? false; + + if (snakeProvided && camelProvided && snakeValue != camelValue) + { + lspCompatible = false; + error = "Pass only one of 'lsp_compatible' or 'lspCompatible', or give both aliases the same value."; + return false; + } + + lspCompatible = snakeProvided ? snakeValue : camelValue; + error = null; + return true; + } + private static int CountTrue(params bool[] values) { return values.Count(value => value); @@ -1227,9 +1394,10 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) if (query.Length > QueryLimits.MaxQueryLength) return CreateToolErrorResponse(id, QueryLimits.FormatQueryTooLongError()); - var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultQueryLimit); + var adjustments = new ArgumentAdjustmentCollector(); + var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); - var snippetLines = SearchSnippetFormatter.ClampSnippetLines(args?["snippetLines"]?.GetValue() ?? SearchSnippetFormatter.DefaultSnippetLines); + var snippetLines = ReadSnippetLines(args, SearchSnippetFormatter.DefaultSnippetLines, adjustments); if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) return maxLineWidthError; var rawQuery = args?["rawQuery"]?.GetValue() ?? false; @@ -1291,6 +1459,7 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) AddExactSubstringRecoveryHint(payload, query); if (countResults.Count == 0) AddFtsQueryDiagnostics(payload, DbReader.AnalyzeFtsQuery(query, rawQuery, prefix, lang)); + adjustments.ApplyTo(payload); return CreateToolResult(id, $"Counted {countResults.Count} search result(s).", payload); } @@ -1338,6 +1507,7 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) new JsonObject { ["query"] = query, ["limit"] = 5 }); } AddFreshnessHint(payload, reader); + adjustments.ApplyTo(payload); return CreateToolResult(id, "No results found.", payload); } @@ -1364,6 +1534,7 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) BuildExcerptArgs(topResult.Path, topResult.StartLine, topResult.EndLine)); if (suggestExactSubstring) AddExactSubstringRecoveryHint(structured, query); + adjustments.ApplyTo(structured); // Include top file paths in summary for quick AI orientation // AIが素早く位置把握できるよう、サマリにトップファイルパスを含める var topPaths = results.Select(r => r.Path).Distinct().Take(3); @@ -1396,9 +1567,10 @@ private JsonNode ExecuteSymbols(JsonNode? id, JsonNode? args) } if (namesProvided && names.Count == 0) return CreateToolErrorResponse(id, "'names' is present but contains no usable entries (all were empty or whitespace)."); + var adjustments = new ArgumentAdjustmentCollector(); var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); - var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultQueryLimit); + var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) return maxLineWidthError; var pathPatterns = ReadScopedPathList(args); @@ -1460,6 +1632,7 @@ private JsonNode ExecuteSymbols(JsonNode? id, JsonNode? args) AddExactGraphSignal(payload, exactSignal); AddExactZeroHint(payload, exactZeroHint); AddFreshnessHint(payload, reader); + adjustments.ApplyTo(payload); return CreateToolResult(id, "No symbols found.", payload); } @@ -1476,6 +1649,7 @@ private JsonNode ExecuteSymbols(JsonNode? id, JsonNode? args) }; if (hasExactPredicate) AddExactGraphSignal(structured, exactSignal); + adjustments.ApplyTo(structured); return CreateToolResult(id, ConsoleUi.FoundSummary(results.Count, "symbol"), structured); }); } @@ -1489,11 +1663,13 @@ private JsonNode ExecuteDefinition(JsonNode? id, JsonNode? args) if (IsBareVerbatimQueryToken(query)) return CreateToolErrorResponse(id, "Add a real symbol name after the command; bare verbatim prefixes like `@` are not valid queries."); + var adjustments = new ArgumentAdjustmentCollector(); var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); - var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultQueryLimit); + var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); var includeBody = args?["includeBody"]?.GetValue() ?? false; - var lspCompatible = args?["lsp_compatible"]?.GetValue() ?? false; + if (!TryReadLspCompatibleArgument(args, out var lspCompatible, out var lspCompatibleError)) + return CreateToolErrorResponse(id, lspCompatibleError!); var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; @@ -1520,6 +1696,7 @@ private JsonNode ExecuteDefinition(JsonNode? id, JsonNode? args) countPayload["lang"] = lang; countPayload["path"] = PathEcho(pathPatterns); countPayload["excludeTests"] = excludeTests; + adjustments.ApplyTo(countPayload); return CreateToolResult(id, $"Counted {ConsoleUi.Counted(total, "definition")}.", countPayload); } if (lspCompatible) @@ -1553,6 +1730,7 @@ private JsonNode ExecuteDefinition(JsonNode? id, JsonNode? args) AddSymbolRecoveryHint(payload, query, "definition", lang, kind, PathEcho(pathPatterns)); AddFreshnessHint(payload, reader); } + adjustments.ApplyTo(payload); return CreateToolResult(id, ConsoleUi.FoundSummary(results.Count, "definition"), payload); @@ -1568,11 +1746,13 @@ private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args) if (IsBareVerbatimQueryToken(query)) return CreateToolErrorResponse(id, "Add a real symbol name after the command; bare verbatim prefixes like `@` are not valid queries."); + var adjustments = new ArgumentAdjustmentCollector(); var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); - var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultQueryLimit); - var lspCompatible = args?["lsp_compatible"]?.GetValue() ?? false; - var offset = ReadOffset(args); + var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); + if (!TryReadLspCompatibleArgument(args, out var lspCompatible, out var lspCompatibleError)) + return CreateToolErrorResponse(id, lspCompatibleError!); + var offset = ReadOffset(args, adjustments); if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) return maxLineWidthError; var pathPatterns = ReadScopedPathList(args); @@ -1599,6 +1779,7 @@ private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args) countOnlyPayload["lang"] = lang; countOnlyPayload["path"] = PathEcho(pathPatterns); countOnlyPayload["excludeTests"] = excludeTests; + adjustments.ApplyTo(countOnlyPayload); return CreateToolResult(id, $"Counted {ConsoleUi.Counted(countOnlyTotal, "reference")}.", countOnlyPayload); } @@ -1656,6 +1837,7 @@ private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args) "excerpt", BuildExcerptArgs(topReference.Path, topReference.Line, topReference.Line)); } + adjustments.ApplyTo(payload); return CreateToolResult(id, BuildGraphSummary("reference", "references", results.Count, graphSupport.GraphLanguage, graphSupport.GraphSupported, graphSupport.GraphSupportReason), payload); @@ -1671,12 +1853,13 @@ private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) if (IsBareVerbatimQueryToken(query)) return CreateToolErrorResponse(id, "Add a real symbol name after the command; bare verbatim prefixes like `@` are not valid queries."); + var adjustments = new ArgumentAdjustmentCollector(); var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); if (IsNonCallGraphReferenceKind(kind)) return CreateToolErrorResponse(id, BuildNonCallGraphKindRejectionMessage("callers", kind!)); var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); - var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultQueryLimit); - var offset = ReadOffset(args); + var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); + var offset = ReadOffset(args, adjustments); var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; @@ -1703,6 +1886,7 @@ private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) countOnlyPayload["lang"] = lang; countOnlyPayload["path"] = PathEcho(pathPatterns); countOnlyPayload["excludeTests"] = excludeTests; + adjustments.ApplyTo(countOnlyPayload); return CreateToolResult(id, $"Counted {ConsoleUi.Counted(countOnlyTotal, "caller")}.", countOnlyPayload); } @@ -1750,6 +1934,7 @@ private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) AddSymbolRecoveryHint(payload, query, "callers", lang, kind, PathEcho(pathPatterns)); AddFreshnessHint(payload, reader); } + adjustments.ApplyTo(payload); return CreateToolResult(id, BuildGraphSummary("caller", "callers", results.Count, graphSupport.GraphLanguage, graphSupport.GraphSupported, graphSupport.GraphSupportReason), payload); @@ -1765,12 +1950,13 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) if (IsBareVerbatimQueryToken(query)) return CreateToolErrorResponse(id, "Add a real symbol name after the command; bare verbatim prefixes like `@` are not valid queries."); + var adjustments = new ArgumentAdjustmentCollector(); var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); if (IsNonCallGraphReferenceKind(kind)) return CreateToolErrorResponse(id, BuildNonCallGraphKindRejectionMessage("callees", kind!)); var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); - var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultQueryLimit); - var offset = ReadOffset(args); + var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); + var offset = ReadOffset(args, adjustments); var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; @@ -1797,6 +1983,7 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) countOnlyPayload["lang"] = lang; countOnlyPayload["path"] = PathEcho(pathPatterns); countOnlyPayload["excludeTests"] = excludeTests; + adjustments.ApplyTo(countOnlyPayload); return CreateToolResult(id, $"Counted {ConsoleUi.Counted(countOnlyTotal, "callee")}.", countOnlyPayload); } @@ -1844,6 +2031,7 @@ private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) AddSymbolRecoveryHint(payload, query, "callees", lang, kind, PathEcho(pathPatterns)); AddFreshnessHint(payload, reader); } + adjustments.ApplyTo(payload); return CreateToolResult(id, BuildGraphSummary("callee", "callees", results.Count, graphSupport.GraphLanguage, graphSupport.GraphSupported, graphSupport.GraphSupportReason), payload); @@ -1855,8 +2043,9 @@ private JsonNode ExecuteFiles(JsonNode? id, JsonNode? args) var query = args?["query"]?.GetValue(); if (query != null && query.Length > QueryLimits.MaxQueryLength) return CreateToolErrorResponse(id, QueryLimits.FormatQueryTooLongError()); + var adjustments = new ArgumentAdjustmentCollector(); var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); - var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultQueryLimit); + var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; @@ -1878,6 +2067,7 @@ private JsonNode ExecuteFiles(JsonNode? id, JsonNode? args) ["results"] = new JsonArray() }; AddFreshnessHint(payload, reader); + adjustments.ApplyTo(payload); return CreateToolResult(id, "No files found.", payload); } @@ -1890,19 +2080,21 @@ private JsonNode ExecuteFiles(JsonNode? id, JsonNode? args) ["count"] = results.Count, ["results"] = JsonSerializer.SerializeToNode(results, _jsonOptions) }; + adjustments.ApplyTo(structured); return CreateToolResult(id, ConsoleUi.FoundSummary(results.Count, "file"), structured); }); } private JsonNode ExecuteMap(JsonNode? id, JsonNode? args) { + var adjustments = new ArgumentAdjustmentCollector(); var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); - var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultMapLimit); + var limit = ReadLimit(args, QueryCommandRunner.DefaultMapLimit, adjustments); var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; var sections = ReadStringList(args, "sections").Select(section => section.ToLowerInvariant()).ToHashSet(StringComparer.Ordinal); - var depth = args?["depth"]?.GetValue(); + var depth = ReadMapDepth(args, adjustments); return WithDbReader(id, args, reader => { @@ -1935,6 +2127,7 @@ private JsonNode ExecuteMap(JsonNode? id, JsonNode? args) var hasFilter = (pathPatterns is { Count: > 0 }) || excludePaths.Count > 0 || excludeTests || lang != null; if (map.FileCount == 0 && hasFilter) AddFreshnessHint(structured, reader); + adjustments.ApplyTo(structured); var summary = map.FileCount > 0 ? "Repo map returned." : hasFilter ? "No files found matching the given filters." : "Repo map returned."; @@ -1978,7 +2171,8 @@ private JsonNode ExecuteAnalyzeSymbol(JsonNode? id, JsonNode? args) if (IsBareVerbatimQueryToken(query)) return CreateToolErrorResponse(id, "Add a real symbol name after the command; bare verbatim prefixes like `@` are not valid queries."); - var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultMapLimit); + var adjustments = new ArgumentAdjustmentCollector(); + var limit = ReadLimit(args, QueryCommandRunner.DefaultMapLimit, adjustments); var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); var includeBody = args?["includeBody"]?.GetValue() ?? false; if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) @@ -2012,6 +2206,7 @@ private JsonNode ExecuteAnalyzeSymbol(JsonNode? id, JsonNode? args) structured["lang"] = lang; structured["path"] = PathEcho(pathPatterns); structured["excludeTests"] = excludeTests; + adjustments.ApplyTo(structured); return CreateToolResult(id, BuildAnalyzeSymbolSummary(analysis), structured); }); } @@ -2267,9 +2462,13 @@ private JsonNode ExecuteStatus(JsonNode? id) ["max_configured_response_bytes"] = MaxConfiguredResponseBytes, ["batch_response_bytes"] = GetBatchQueryResponseByteLimit(), ["max_batch_response_bytes"] = MaxBatchQueryResponseByteLimit, + ["batch_query_response_bytes"] = GetBatchQueryResponseByteLimit(), + ["batch_query_max_response_bytes"] = MaxBatchQueryResponseByteLimit, + ["batch_query_max_queries"] = MaxBatchQuerySize, ["max_pagination_offset"] = MaxMcpPaginationOffset, ["max_json_depth"] = MaxJsonDepth, ["max_batch_requests"] = MaxBatchRequestCount, + ["json_rpc_batch_max_requests"] = MaxBatchRequestCount, ["keep_alive_min_interval_s"] = MinKeepAliveIntervalSeconds, ["keep_alive_max_interval_s"] = MaxKeepAliveIntervalSeconds, ["rate_limit_max_rps"] = RateLimiterOptions.MaxRefillTokensPerSecond, @@ -2562,7 +2761,8 @@ private JsonNode ExecuteFindInFile(JsonNode? id, JsonNode? args) ? "Parameter \"path\" cannot be empty or whitespace-only" : "Missing required parameter: path"); - var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultQueryLimit); + var adjustments = new ArgumentAdjustmentCollector(); + var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; @@ -2633,10 +2833,12 @@ private JsonNode ExecuteFindInFile(JsonNode? id, JsonNode? args) if (results.Count == 0) { AddFreshnessHint(structured, reader); + adjustments.ApplyTo(structured); return CreateToolResult(id, "No matches found.", structured); } var fileCount = structured["fileCount"]!.GetValue(); + adjustments.ApplyTo(structured); return CreateToolResult(id, $"Found {ConsoleUi.Counted(results.Count, "in-file match", "in-file matches")} across {ConsoleUi.Counted(fileCount, "file")}.", structured); }); } @@ -2652,23 +2854,26 @@ private JsonNode ExecuteBatchQuery(JsonNode? id, JsonNode? args) if (queries == null || queries.Count == 0) return CreateToolErrorResponse(id, "Missing or empty required parameter: queries"); - const int maxBatchSize = 10; - if (queries.Count > maxBatchSize) - return CreateToolErrorResponse(id, $"Batch too large: {queries.Count} queries (max {maxBatchSize})"); + if (queries.Count > MaxBatchQuerySize) + return CreateToolErrorResponse(id, $"Batch too large: {queries.Count} queries (max {MaxBatchQuerySize})"); var resultsArray = new JsonArray(); var truncatedQueries = new JsonArray(); var totalStopwatch = Stopwatch.StartNew(); + var adjustments = new ArgumentAdjustmentCollector(); int successCount = 0; int failureCount = 0; int? cascadeStartedAtIndex = null; var truncated = false; - var responseByteLimit = GetBatchQueryResponseByteLimit(); + var responseByteLimit = ReadBatchQueryResponseByteLimit(args, adjustments); + var estimateOnly = args?["estimateOnly"]?.GetValue() ?? false; + if (estimateOnly) + return ExecuteBatchQueryEstimate(id, queries, responseByteLimit, adjustments); var estimatedResponseBytes = EstimateBatchResponseBytes(id, "Executed 0 queries.", queries.Count, successCount, failureCount, GetBatchFailureScope(queries.Count, successCount, failureCount, cascadeStartedAtIndex), cascadeStartedAtIndex, - responseByteLimit, resultsArray, truncated: false, truncatedQueries); + responseByteLimit, resultsArray, truncated: false, truncatedQueries, adjustments); - bool TryAppendResult(JsonObject entry, string? toolName, JsonNode? toolArgs, int requestIndex, bool successfulSlot = false, bool failedSlot = false) + bool TryAppendResult(JsonObject entry, string? toolName, JsonNode? toolArgs, int requestIndex, string? slotId, bool successfulSlot = false, bool failedSlot = false) { var candidateResults = CloneJsonArray(resultsArray); candidateResults.Add(entry.DeepClone()); @@ -2680,7 +2885,7 @@ bool TryAppendResult(JsonObject entry, string? toolName, JsonNode? toolArgs, int : $"Executed {candidateExecutedCount} of {queries.Count} queries in 0 ms ({candidateSuccessCount} succeeded, {candidateFailureCount} failed)."; var candidateBytes = EstimateBatchResponseBytes(id, candidateSummary, queries.Count, candidateSuccessCount, candidateFailureCount, GetBatchFailureScope(queries.Count, candidateSuccessCount, candidateFailureCount, cascadeStartedAtIndex), cascadeStartedAtIndex, - responseByteLimit, candidateResults, truncated: false, truncatedQueries); + responseByteLimit, candidateResults, truncated: false, truncatedQueries, adjustments); if (candidateBytes > responseByteLimit) { truncated = true; @@ -2691,6 +2896,7 @@ bool TryAppendResult(JsonObject entry, string? toolName, JsonNode? toolArgs, int ["args_summary"] = BuildArgsSummary(toolArgs), ["reason"] = "response_byte_limit_exceeded", }; + AddBatchSlotId(truncatedEntry, slotId); AddToolDisplayData(truncatedEntry, toolName); truncatedQueries.Add(truncatedEntry); return false; @@ -2716,7 +2922,7 @@ static void CopySlotErrorData(JsonObject entry, JsonObject? extraData) } } - void AppendSlotError(int requestIndex, string? toolName, JsonNode? toolArgs, Stopwatch slotStopwatch, string errorMessage, + void AppendSlotError(int requestIndex, string? slotId, string? toolName, JsonNode? toolArgs, Stopwatch slotStopwatch, string errorMessage, int? code = null, string? category = null, string? suggestion = null, bool? retrySafe = null, JsonObject? extraData = null) { slotStopwatch.Stop(); @@ -2729,6 +2935,7 @@ void AppendSlotError(int requestIndex, string? toolName, JsonNode? toolArgs, Sto ["elapsed_ms"] = slotStopwatch.ElapsedMilliseconds, ["error"] = errorMessage, }; + AddBatchSlotId(entry, slotId); AddToolDisplayData(entry, toolName); CopySlotErrorData(entry, extraData); if (code.HasValue) @@ -2744,7 +2951,7 @@ void AppendSlotError(int requestIndex, string? toolName, JsonNode? toolArgs, Sto entry["suggestion"] = suggestion; if (retrySafe.HasValue) entry["retry_safe"] = retrySafe.Value; - TryAppendResult(entry, toolName, toolArgs, requestIndex, failedSlot: true); + TryAppendResult(entry, toolName, toolArgs, requestIndex, slotId, failedSlot: true); failureCount++; } @@ -2759,7 +2966,7 @@ void AppendSlotError(int requestIndex, string? toolName, JsonNode? toolArgs, Sto // 検出・バックオフを可能にする。外側の batch_query 自体もトークンを消費するため、 // N 個の内側呼び出しを含むスパムは batch_query バケットとツール別バケットの両方で // 上限が掛かる(#1560)。 - void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArgs, Stopwatch slotStopwatch, long retryAfterMs) + void AppendRateLimitedSlot(int requestIndex, string? slotId, string? toolName, JsonNode? toolArgs, Stopwatch slotStopwatch, long retryAfterMs) { slotStopwatch.Stop(); var toolDisplay = toolName is null ? "(missing)" : BoundToolNameForDisplay(toolName).Text; @@ -2783,8 +2990,9 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg ["suggestion"] = $"Back off for at least {retryAfterMs} ms before retrying this tool.", ["retry_safe"] = true, }; + AddBatchSlotId(entry, slotId); AddToolDisplayData(entry, toolName); - TryAppendResult(entry, toolName, toolArgs, requestIndex, failedSlot: true); + TryAppendResult(entry, toolName, toolArgs, requestIndex, slotId, failedSlot: true); failureCount++; } @@ -2797,6 +3005,7 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg ? parsedToolName : null; var toolArgs = queryObject?["arguments"]; + var slotId = ReadBatchSlotId(queryObject); var slotStopwatch = Stopwatch.StartNew(); if (truncated) @@ -2809,6 +3018,7 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg ["args_summary"] = BuildArgsSummary(toolArgs), ["reason"] = "response_byte_limit_already_exceeded", }; + AddBatchSlotId(truncatedEntry, slotId); AddToolDisplayData(truncatedEntry, toolName); truncatedQueries.Add(truncatedEntry); continue; @@ -2817,7 +3027,7 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg if (string.IsNullOrEmpty(toolName)) { 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, + AppendSlotError(requestIndex, slotId, toolName, toolArgs, slotStopwatch, message, category: McpErrorEnvelope.CategoryMissingParameter, suggestion: "Each batch_query slot must include a string `tool` field.", retrySafe: false); @@ -2825,7 +3035,7 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg } if (toolName.Length > McpBoundedText.MaxToolNameChars) { - AppendSlotError(requestIndex, toolName, toolArgs, slotStopwatch, BuildUnknownToolMessage(toolName), + AppendSlotError(requestIndex, slotId, toolName, toolArgs, slotStopwatch, BuildUnknownToolMessage(toolName), category: McpErrorEnvelope.CategoryToolUnknown, suggestion: "Call tools/list to see the tool catalog. Slot tool names are case-sensitive.", retrySafe: false); @@ -2834,7 +3044,7 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg if (ValidateToolArguments(toolName, toolArgs) is JsonObject argumentError) { - AppendSlotError(requestIndex, toolName, toolArgs, slotStopwatch, argumentError["message"]!.GetValue(), + AppendSlotError(requestIndex, slotId, toolName, toolArgs, slotStopwatch, argumentError["message"]!.GetValue(), category: McpErrorEnvelope.CategoryInvalidArgument, suggestion: "Use exactly the argument names advertised by tools/list for this tool.", retrySafe: false, @@ -2844,7 +3054,7 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg if (ValidateCommonListArguments(toolArgs) is JsonObject listArgumentError) { - AppendSlotError(requestIndex, toolName, toolArgs, slotStopwatch, listArgumentError["message"]!.GetValue(), + AppendSlotError(requestIndex, slotId, toolName, toolArgs, slotStopwatch, listArgumentError["message"]!.GetValue(), category: McpErrorEnvelope.CategoryInvalidArgument, suggestion: "Send only non-empty string entries within the documented MCP array bounds.", retrySafe: false, @@ -2869,7 +3079,7 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg // 前にこのゲートを置く。 if (McpToolFilter.IsKnownTool(toolName) && !_toolFilter.IsEnabled(toolName)) { - AppendSlotError(requestIndex, toolName, toolArgs, slotStopwatch, $"Tool not enabled: {toolName}", code: -32601, + AppendSlotError(requestIndex, slotId, 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); @@ -2879,7 +3089,7 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg // Block write operations in batch / バッチ内では書き込み操作をブロック if (toolName == "index" || toolName == "backfill_fold" || toolName == "suggest_improvement") { - AppendSlotError(requestIndex, toolName, toolArgs, slotStopwatch, $"{toolName} is not allowed in batch_query (write operation)", + AppendSlotError(requestIndex, slotId, 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); @@ -2894,7 +3104,7 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg // ネスト禁止の明示文に揃える(#1560)。 if (toolName == "batch_query") { - AppendSlotError(requestIndex, toolName, toolArgs, slotStopwatch, "batch_query cannot be nested inside batch_query.", + AppendSlotError(requestIndex, slotId, 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); @@ -2912,13 +3122,13 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg var slotDecision = RateLimiter.TryAcquire(toolName, _caller); if (!slotDecision.Allowed) { - AppendRateLimitedSlot(requestIndex, toolName, toolArgs, slotStopwatch, slotDecision.RetryAfterMs); + AppendRateLimitedSlot(requestIndex, slotId, toolName, toolArgs, slotStopwatch, slotDecision.RetryAfterMs); continue; } if (ValidateProjectFilterArguments(toolArgs) is JsonObject projectFilterError) { - AppendSlotError(requestIndex, toolName, toolArgs, slotStopwatch, projectFilterError["message"]!.GetValue(), + AppendSlotError(requestIndex, slotId, toolName, toolArgs, slotStopwatch, projectFilterError["message"]!.GetValue(), category: McpErrorEnvelope.CategoryInvalidArgument, suggestion: "Use a project name or project path from the current workspace, or correct the solution filter.", retrySafe: false, @@ -2956,7 +3166,7 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg if (response == null) { - AppendSlotError(requestIndex, toolName, toolArgs, slotStopwatch, BuildUnknownToolMessage(toolName), + AppendSlotError(requestIndex, slotId, toolName, toolArgs, slotStopwatch, BuildUnknownToolMessage(toolName), category: McpErrorEnvelope.CategoryToolUnknown, suggestion: "Call tools/list to see the tool catalog. Slot tool names are case-sensitive.", retrySafe: false); @@ -2982,7 +3192,7 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg bool? innerRetrySafe = null; if (innerStructured?["retry_safe"] is JsonValue rv && rv.TryGetValue(out var rb)) innerRetrySafe = rb; - AppendSlotError(requestIndex, toolName, toolArgs, slotStopwatch, errorText, + AppendSlotError(requestIndex, slotId, toolName, toolArgs, slotStopwatch, errorText, category: innerCategory, suggestion: innerSuggestion, retrySafe: innerRetrySafe); @@ -2991,6 +3201,7 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg slotStopwatch.Stop(); var structured = response["result"]?["structuredContent"]; + var slotSummary = response["result"]?["content"]?[0]?["text"]?.GetValue(); var entry = new JsonObject { ["request_index"] = requestIndex, @@ -2998,10 +3209,12 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg ["correlation_id"] = CurrentCorrelationContext.Value?.CorrelationId, ["args_summary"] = BuildArgsSummary(toolArgs), ["elapsed_ms"] = slotStopwatch.ElapsedMilliseconds, + ["summary"] = slotSummary, ["result"] = structured?.DeepClone(), }; + AddBatchSlotId(entry, slotId); AddToolDisplayData(entry, toolName); - TryAppendResult(entry, toolName, toolArgs, requestIndex, successfulSlot: true); + TryAppendResult(entry, toolName, toolArgs, requestIndex, slotId, successfulSlot: true); successCount++; } catch (Exception ex) @@ -3015,7 +3228,7 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg Database.DbDebug.DumpToStderr(ex); }); var classification = McpErrorEnvelope.ClassifyException(ex); - AppendSlotError(requestIndex, toolName, toolArgs, slotStopwatch, BuildSanitizedToolErrorMessage(toolName, ex), + AppendSlotError(requestIndex, slotId, toolName, toolArgs, slotStopwatch, BuildSanitizedToolErrorMessage(toolName, ex), category: classification.Category, suggestion: classification.Suggestion, retrySafe: classification.RetrySafe); @@ -3024,28 +3237,33 @@ void AppendRateLimitedSlot(int requestIndex, string? toolName, JsonNode? toolArg totalStopwatch.Stop(); var totalElapsedMs = totalStopwatch.ElapsedMilliseconds; - JsonObject BuildPayload() => new() + JsonObject BuildPayload() { - ["count"] = resultsArray.Count, - ["total_count"] = queries.Count, - ["success_count"] = successCount, - ["failure_count"] = failureCount, - ["partial_failure"] = failureCount > 0 || cascadeStartedAtIndex.HasValue, - ["failure_scope"] = GetBatchFailureScope(queries.Count, successCount, failureCount, cascadeStartedAtIndex), - ["cascade_started_at_index"] = cascadeStartedAtIndex, - ["metadata"] = new JsonObject + var payload = new JsonObject { - ["submitted"] = queries.Count, - ["executed"] = successCount + failureCount, - ["errors"] = failureCount, - ["total_elapsed_ms"] = totalElapsedMs, + ["count"] = resultsArray.Count, + ["total_count"] = queries.Count, ["success_count"] = successCount, ["failure_count"] = failureCount, - ["response_byte_limit"] = responseByteLimit, - ["estimated_response_bytes"] = responseByteLimit, - }, - ["results"] = resultsArray.DeepClone(), - }; + ["partial_failure"] = failureCount > 0 || cascadeStartedAtIndex.HasValue, + ["failure_scope"] = GetBatchFailureScope(queries.Count, successCount, failureCount, cascadeStartedAtIndex), + ["cascade_started_at_index"] = cascadeStartedAtIndex, + ["metadata"] = new JsonObject + { + ["submitted"] = queries.Count, + ["executed"] = successCount + failureCount, + ["errors"] = failureCount, + ["total_elapsed_ms"] = totalElapsedMs, + ["success_count"] = successCount, + ["failure_count"] = failureCount, + ["response_byte_limit"] = responseByteLimit, + ["estimated_response_bytes"] = responseByteLimit, + }, + ["results"] = resultsArray.DeepClone(), + }; + adjustments.ApplyTo(payload); + return payload; + } string BuildSummary() { @@ -3066,6 +3284,7 @@ string BuildSummary() { payload["truncated"] = true; payload["truncated_queries"] = truncatedQueries.DeepClone(); + payload["split_hint"] = BuildBatchSplitHint(queries.Count, cascadeStartedAtIndex, resultsArray.Count); } summary = BuildSummary(); @@ -3075,9 +3294,18 @@ string BuildSummary() if (resultsArray.Count > 0) { var removed = resultsArray[resultsArray.Count - 1]; + truncated = true; + if (removed?["request_index"] is JsonValue requestIndexValue + && requestIndexValue.TryGetValue(out var removedRequestIndex)) + { + cascadeStartedAtIndex = cascadeStartedAtIndex.HasValue + ? Math.Min(cascadeStartedAtIndex.Value, removedRequestIndex) + : removedRequestIndex; + } truncatedQueries.Insert(0, new JsonObject { ["request_index"] = removed?["request_index"]?.DeepClone(), + ["slot_id"] = removed?["slot_id"]?.DeepClone(), ["tool"] = removed?["tool"]?.DeepClone(), ["args_summary"] = removed?["args_summary"]?.DeepClone(), ["reason"] = "final_response_byte_limit_exceeded", @@ -3097,6 +3325,127 @@ string BuildSummary() return CreateToolResult(id, summary, payload); } + private JsonNode ExecuteBatchQueryEstimate(JsonNode? id, JsonArray queries, int responseByteLimit, ArgumentAdjustmentCollector adjustments) + { + var slotEstimates = new JsonArray(); + for (var requestIndex = 0; requestIndex < queries.Count; requestIndex++) + { + var queryObject = queries[requestIndex] as JsonObject; + slotEstimates.Add(BuildBatchSlotDescriptor(requestIndex, queryObject)); + } + + var payload = new JsonObject + { + ["count"] = 0, + ["total_count"] = queries.Count, + ["success_count"] = 0, + ["failure_count"] = 0, + ["partial_failure"] = false, + ["failure_scope"] = "none", + ["cascade_started_at_index"] = null, + ["estimate_only"] = true, + ["metadata"] = new JsonObject + { + ["submitted"] = queries.Count, + ["executed"] = 0, + ["errors"] = 0, + ["total_elapsed_ms"] = 0, + ["success_count"] = 0, + ["failure_count"] = 0, + ["response_byte_limit"] = responseByteLimit, + ["estimated_response_bytes"] = responseByteLimit, + }, + ["slot_estimates"] = slotEstimates, + ["results"] = new JsonArray(), + }; + adjustments.ApplyTo(payload); + + var summary = $"Estimated batch_query envelope for {queries.Count} query slot(s); no slots executed."; + var estimatedResponseBytes = EstimateJsonUtf8Bytes(CreateToolResult(id, summary, payload.DeepClone()), responseByteLimit); + ((JsonObject)payload["metadata"]!)["estimated_response_bytes"] = estimatedResponseBytes; + payload["estimate_exceeds_response_byte_limit"] = estimatedResponseBytes > responseByteLimit; + return CreateToolResult(id, summary, payload); + } + + private static int ReadBatchQueryResponseByteLimit(JsonNode? args, ArgumentAdjustmentCollector adjustments) + { + var serverLimit = GetBatchQueryResponseByteLimit(); + var requested = args?["maxResponseBytes"]?.GetValue(); + if (!requested.HasValue) + return serverLimit; + var effective = Math.Min(requested.Value, serverLimit); + if (effective != requested.Value) + adjustments.AddClamped("maxResponseBytes", requested.Value, effective, 1, serverLimit); + return effective; + } + + private static JsonObject BuildBatchSlotDescriptor(int requestIndex, JsonObject? queryObject) + { + var toolName = queryObject?["tool"] is JsonValue toolValue && toolValue.TryGetValue(out var parsedToolName) + ? parsedToolName + : null; + var toolArgs = queryObject?["arguments"]; + var descriptor = new JsonObject + { + ["request_index"] = requestIndex, + ["args_summary"] = BuildArgsSummary(toolArgs), + }; + AddBatchSlotId(descriptor, ReadBatchSlotId(queryObject)); + AddToolDisplayData(descriptor, toolName); + return descriptor; + } + + private static JsonObject BuildBatchSplitHint(int submittedCount, int? cascadeStartedAtIndex, int retainedResultCount) + { + var nextRequestIndex = cascadeStartedAtIndex ?? submittedCount; + return new JsonObject + { + ["reason"] = "response_byte_limit_exceeded", + ["next_request_index"] = nextRequestIndex, + ["suggested_query_count"] = Math.Max(1, retainedResultCount), + ["resume_cursor"] = $"batch_query:v1:{nextRequestIndex}", + }; + } + + private static string? ReadBatchSlotId(JsonObject? queryObject) + { + if (TryReadBatchSlotIdValue(queryObject?["slotId"], out var slotId) + || TryReadBatchSlotIdValue(queryObject?["id"], out slotId)) + return McpBoundedText.ForDisplay(slotId!, MaxRequestIdCharacterCount).Text; + return null; + } + + private static bool TryReadBatchSlotIdValue(JsonNode? node, out string? slotId) + { + slotId = null; + if (node is not JsonValue value) + return false; + if (value.TryGetValue(out var text)) + { + if (string.IsNullOrWhiteSpace(text)) + return false; + slotId = text; + return true; + } + if (value.TryGetValue(out var intValue)) + { + slotId = intValue.ToString(CultureInfo.InvariantCulture); + return true; + } + if (value.TryGetValue(out var longValue)) + { + slotId = longValue.ToString(CultureInfo.InvariantCulture); + return true; + } + return false; + } + + private static void AddBatchSlotId(JsonObject entry, string? slotId) + { + if (!string.IsNullOrEmpty(slotId)) + entry["slot_id"] = slotId; + } + private static int GetBatchQueryResponseByteLimit() => ReadPositiveIntEnvironmentLimit( BatchQueryResponseByteLimitEnvVar, @@ -3111,7 +3460,8 @@ private int EstimateJsonUtf8Bytes(JsonNode node, int maxBytes = int.MaxValue) } private int EstimateBatchResponseBytes(JsonNode? id, string summary, int submittedCount, int successCount, int failureCount, - string failureScope, int? cascadeStartedAtIndex, int responseByteLimit, JsonArray resultsArray, bool truncated, JsonArray truncatedQueries) + string failureScope, int? cascadeStartedAtIndex, int responseByteLimit, JsonArray resultsArray, bool truncated, JsonArray truncatedQueries, + ArgumentAdjustmentCollector? adjustments = null) { var payload = new JsonObject { @@ -3140,6 +3490,7 @@ private int EstimateBatchResponseBytes(JsonNode? id, string summary, int submitt payload["truncated"] = true; payload["truncated_queries"] = truncatedQueries.DeepClone(); } + adjustments?.ApplyTo(payload); return EstimateJsonUtf8Bytes(CreateToolResult(id, summary, payload), responseByteLimit); } @@ -3197,7 +3548,8 @@ JsonValue v when v.TryGetValue(out var text) => JsonSerializer.Serialize private JsonNode ExecuteDeps(JsonNode? id, JsonNode? args) { - var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultImpactLimit); + var adjustments = new ArgumentAdjustmentCollector(); + var limit = ReadLimit(args, QueryCommandRunner.DefaultImpactLimit, adjustments); var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); @@ -3243,6 +3595,7 @@ private JsonNode ExecuteDeps(JsonNode? id, JsonNode? args) : "No file dependencies found."; if (results.Count == 0) AddFreshnessHint(payload, reader); + adjustments.ApplyTo(payload); return CreateToolResult(id, summary, payload); }); } @@ -3282,9 +3635,22 @@ private JsonNode ExecuteImpactAnalysis(JsonNode? id, JsonNode? args) var maxHopsNode = args?["maxHops"]; var deprecatedMaxDepthNode = args?["maxDepth"]; var usedDeprecatedMaxDepth = deprecatedMaxDepthNode != null; + var adjustments = new ArgumentAdjustmentCollector(); var maxDepthRequested = maxHopsNode?.GetValue() ?? deprecatedMaxDepthNode?.GetValue() ?? 5; var maxDepth = Math.Clamp(maxDepthRequested, 0, MaxImpactDepth); - var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultImpactLimit); + string? maxDepthClampWarning = null; + string? maxDepthDeprecationWarning = null; + if (usedDeprecatedMaxDepth) + { + maxDepthDeprecationWarning = "maxDepth is deprecated for impact_analysis; use maxHops instead."; + adjustments.AddWarning(maxDepthDeprecationWarning); + } + if (maxDepthRequested != maxDepth) + { + maxDepthClampWarning = $"maxHops was clamped from {maxDepthRequested} to {maxDepth} (server cap is [0, {MaxImpactDepth}])."; + adjustments.AddClamped("maxHops", maxDepthRequested, maxDepth, 0, MaxImpactDepth); + } + var limit = ReadLimit(args, QueryCommandRunner.DefaultImpactLimit, adjustments); var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); @@ -3337,6 +3703,7 @@ private JsonNode ExecuteImpactAnalysis(JsonNode? id, JsonNode? args) }; AddImpactFailureFields(countOnlyPayload, analysis); AddSqlGraphContractSignal(countOnlyPayload, sqlGraphSignal); + adjustments.ApplyTo(countOnlyPayload); return CreateToolResult(id, $"Counted {ConsoleUi.Counted(count, "impact result")}.", countOnlyPayload); } @@ -3375,21 +3742,6 @@ private JsonNode ExecuteImpactAnalysis(JsonNode? id, JsonNode? args) if (analysis.Cycles is { Count: > 0 }) payload["cycles"] = ToJsonArray(analysis.Cycles); AddSqlGraphContractSignal(payload, sqlGraphSignal); - var warnings = new JsonArray(); - string? maxDepthClampWarning = null; - string? maxDepthDeprecationWarning = null; - if (usedDeprecatedMaxDepth) - { - maxDepthDeprecationWarning = "maxDepth is deprecated for impact_analysis; use maxHops instead."; - warnings.Add(maxDepthDeprecationWarning); - } - if (maxDepthRequested != maxDepth) - { - maxDepthClampWarning = $"maxHops was clamped from {maxDepthRequested} to {maxDepth} (server cap is [0, {MaxImpactDepth}])."; - warnings.Add(maxDepthClampWarning); - } - if (warnings.Count > 0) - payload["warnings"] = warnings; if (analysis.ZeroResultReason != null) payload["zero_result_reason"] = analysis.ZeroResultReason; AddImpactFailureFields(payload, analysis); @@ -3435,6 +3787,7 @@ private JsonNode ExecuteImpactAnalysis(JsonNode? id, JsonNode? args) } else if (analysis.Heuristic) payload["note"] = "file_impacts are heuristic hints only; the current graph does not record resolved target file/type for each call."; + adjustments.ApplyTo(payload); return CreateToolResult(id, summary, payload); }); } @@ -3475,7 +3828,8 @@ private JsonNode ExecuteValidate(JsonNode? id, JsonNode? args) private JsonNode ExecuteSymbolHotspots(JsonNode? id, JsonNode? args) { - var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultQueryLimit); + var adjustments = new ArgumentAdjustmentCollector(); + var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); var groupBy = args?["groupBy"]?.GetValue()?.ToLowerInvariant() @@ -3582,13 +3936,15 @@ private JsonNode ExecuteSymbolHotspots(JsonNode? id, JsonNode? args) new JsonObject()); AddFreshnessHint(payload, reader); } + adjustments.ApplyTo(payload); return CreateToolResult(id, summary, payload); }); } private JsonNode ExecuteUnusedSymbols(JsonNode? id, JsonNode? args) { - var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultImpactLimit); + var adjustments = new ArgumentAdjustmentCollector(); + var limit = ReadLimit(args, QueryCommandRunner.DefaultImpactLimit, adjustments); var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); var bucket = args?["bucket"]?.GetValue()?.ToLowerInvariant(); @@ -3661,6 +4017,7 @@ private JsonNode ExecuteUnusedSymbols(JsonNode? id, JsonNode? args) new JsonObject()); AddFreshnessHint(payload, reader); } + adjustments.ApplyTo(payload); return CreateToolResult(id, summary, payload); }); } diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 798a93e3f6..51b2e5e21f 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -3829,8 +3829,11 @@ public void ToolsList_CommonSchemasAdvertiseClientSideConstraints() Assert.Equal(QueryCommandRunner.MaxQueryPathFilterCount, pathArraySchema["maxItems"]!.GetValue()); Assert.Equal(QueryCommandRunner.MaxQueryPathFilterLength, pathArraySchema["items"]!["maxLength"]!.GetValue()); var excludePathsSchema = searchProperties["excludePaths"]!; - Assert.Equal(QueryCommandRunner.MaxQueryPathFilterCount, excludePathsSchema["maxItems"]!.GetValue()); - Assert.Equal(QueryCommandRunner.MaxQueryPathFilterLength, excludePathsSchema["items"]!["maxLength"]!.GetValue()); + var excludePathsStringSchema = excludePathsSchema["oneOf"]!.AsArray()[0]!; + Assert.Equal(QueryCommandRunner.MaxQueryPathFilterLength, excludePathsStringSchema["maxLength"]!.GetValue()); + var excludePathsArraySchema = excludePathsSchema["oneOf"]!.AsArray()[1]!; + Assert.Equal(QueryCommandRunner.MaxQueryPathFilterCount, excludePathsArraySchema["maxItems"]!.GetValue()); + Assert.Equal(QueryCommandRunner.MaxQueryPathFilterLength, excludePathsArraySchema["items"]!["maxLength"]!.GetValue()); var referencesTool = tools.First(t => t!["name"]!.GetValue() == "references")!; var kindEnum = referencesTool["inputSchema"]!["properties"]!["kind"]!["enum"]!.AsArray() @@ -4138,19 +4141,123 @@ public void McpToolFilter_Parse_AllowWinsOverDeny() } [Fact] - public void McpToolFilter_Parse_UnknownNamesInDenyListDoNotAffectKnownTools() + public void McpToolFilter_Parse_UnknownNamesWarnAndKeepFilterSemantics_Issue3406() { - // A typo in CDIDX_MCP_TOOLS_DENY simply does not match anything; the known set stays - // enabled. Allowlist semantics deliberately differ: a non-empty allowlist is treated - // as a strict pin, so an allowlist of only-unknown names exposes nothing — that empty - // surface is visible at the next tools/list call. - var denyFilter = McpToolFilter.Parse(null, "bogus_tool"); - foreach (var name in McpToolFilter.KnownToolNames) - Assert.True(denyFilter.IsEnabled(name), $"{name} should remain enabled when denylist names only unknown tools"); + lock (TestConsoleLock.Gate) + { + var originalError = Console.Error; + using var stderr = new StringWriter(); + try + { + Console.SetError(stderr); - var allowFilter = McpToolFilter.Parse("bogus_tool", null); - foreach (var name in McpToolFilter.KnownToolNames) - Assert.False(allowFilter.IsEnabled(name), $"{name} should be disabled when allowlist only names unknown tools"); + // A typo in CDIDX_MCP_TOOLS_DENY simply does not match anything; the known set + // stays enabled, but the operator now gets a bounded warning. + // CDIDX_MCP_TOOLS_DENY の typo は何にも一致しないため既知ツールは有効のまま。 + // ただし、オペレータに bounded warning を出す。 + var denyFilter = McpToolFilter.Parse(null, "bogus_tool"); + foreach (var name in McpToolFilter.KnownToolNames) + Assert.True(denyFilter.IsEnabled(name), $"{name} should remain enabled when denylist names only unknown tools"); + + // Allowlist semantics deliberately differ: an allowlist with no known names + // fails closed and exposes nothing. + // allowlist は厳格に扱い、既知名が 0 件なら fail closed で何も公開しない。 + var allowFilter = McpToolFilter.Parse("bogus_tool", null); + foreach (var name in McpToolFilter.KnownToolNames) + Assert.False(allowFilter.IsEnabled(name), $"{name} should be disabled when allowlist only names unknown tools"); + + var warning = stderr.ToString(); + Assert.Contains(McpToolFilter.DenyEnvVarName, warning); + Assert.Contains(McpToolFilter.AllowEnvVarName, warning); + Assert.Contains("unknown MCP tool name", warning); + Assert.Contains("failing closed", warning); + } + finally + { + Console.SetError(originalError); + } + } + } + + [Fact] + public void McpToolFilter_Parse_EmptyAllowListWarnsAndFailsClosed_Issue3406() + { + lock (TestConsoleLock.Gate) + { + var originalError = Console.Error; + using var stderr = new StringWriter(); + try + { + Console.SetError(stderr); + var filter = McpToolFilter.Parse(" ", null); + + foreach (var name in McpToolFilter.KnownToolNames) + Assert.False(filter.IsEnabled(name), $"{name} should be disabled when allowlist is explicitly empty"); + var warning = stderr.ToString(); + Assert.Contains(McpToolFilter.AllowEnvVarName, warning); + Assert.Contains("empty", warning); + Assert.Contains("failing closed", warning); + } + finally + { + Console.SetError(originalError); + } + } + } + + [Fact] + public void McpToolFilter_Parse_EmptyDenyListWarnsAndKeepsDefaults_Issue3406() + { + lock (TestConsoleLock.Gate) + { + var originalError = Console.Error; + using var stderr = new StringWriter(); + try + { + Console.SetError(stderr); + var filter = McpToolFilter.Parse(null, ""); + + foreach (var name in McpToolFilter.KnownToolNames) + Assert.True(filter.IsEnabled(name), $"{name} should remain enabled when denylist is explicitly empty"); + var warning = stderr.ToString(); + Assert.Contains(McpToolFilter.DenyEnvVarName, warning); + Assert.Contains("empty", warning); + Assert.DoesNotContain("failing closed", warning); + } + finally + { + Console.SetError(originalError); + } + } + } + + [Fact] + public void McpToolFilter_Parse_UnknownNameWarningIsBounded_Issue3406() + { + lock (TestConsoleLock.Gate) + { + var originalError = Console.Error; + using var stderr = new StringWriter(); + try + { + Console.SetError(stderr); + var unknownNames = Enumerable.Range(0, McpToolFilter.MaxToolFilterUnknownNamesReported + 2) + .Select(i => $"bogus_tool_{i}"); + + var filter = McpToolFilter.Parse(string.Join(',', unknownNames.Prepend("search")), null); + + Assert.True(filter.IsEnabled("search")); + Assert.False(filter.IsEnabled("references")); + var warning = stderr.ToString(); + Assert.Contains("unknown MCP tool names", warning); + Assert.Contains("more", warning); + Assert.DoesNotContain($"bogus_tool_{McpToolFilter.MaxToolFilterUnknownNamesReported + 1}", warning); + } + finally + { + Console.SetError(originalError); + } + } } [Fact] @@ -4325,6 +4432,42 @@ public void ToolsCall_Search_ReturnsResults() Assert.Null(structured["results"]![0]!["content"]); } + [Fact] + public void ToolsCall_Search_AcceptsScalarExcludePaths_Issue3538() + { + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"query":"App","excludePaths":"src/app.cs"}}}""")!; + var response = _server.HandleMessage(request)!; + + Assert.Null(response["error"]); + var structured = response["result"]!["structuredContent"]!; + Assert.Equal(0, structured["count"]!.GetValue()); + } + + [Fact] + public void ToolsCall_Definition_AcceptsLspCompatibleAlias_Issue3538() + { + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"definition","arguments":{"query":"App","lspCompatible":true}}}""")!; + var response = _server.HandleMessage(request)!; + + Assert.Null(response["error"]); + var structured = response["result"]!["structuredContent"]!; + Assert.True(structured["lspCompatible"]!.GetValue()); + Assert.Equal("file", structured["results"]![0]!["uri"]!.GetValue().Split(':')[0]); + } + + [Fact] + public void ToolsCall_DeprecatedAliasTypeError_CarriesCompatibilityMetadata_Issue3538() + { + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"references","arguments":{"query":"App","exact":"yes"}}}""")!; + var response = _server.HandleMessage(request)!; + + Assert.Null(response["result"]); + var data = response["error"]!["data"]!; + Assert.Equal("exactName", data["alias_of"]!.GetValue()); + Assert.True(data["deprecated"]!.GetValue()); + Assert.Equal("boolean", data["expected"]!.GetValue()); + } + [Fact] public void ToolsCall_Search_GuardFiltersReturnEvidence_Issue2852() { @@ -6689,6 +6832,30 @@ public void ToolsCall_Status_ReportsResponseByteLimitCaps() Assert.Equal(McpServer.MaxConfiguredResponseBytes, limits["max_configured_response_bytes"]!.GetValue()); Assert.Equal(McpServer.MaxBatchQueryResponseByteLimit, limits["batch_response_bytes"]!.GetValue()); Assert.Equal(McpServer.MaxBatchQueryResponseByteLimit, limits["max_batch_response_bytes"]!.GetValue()); + Assert.Equal(McpServer.MaxBatchQueryResponseByteLimit, limits["batch_query_response_bytes"]!.GetValue()); + Assert.Equal(McpServer.MaxBatchQueryResponseByteLimit, limits["batch_query_max_response_bytes"]!.GetValue()); + Assert.Equal(McpServer.MaxBatchQuerySize, limits["batch_query_max_queries"]!.GetValue()); + Assert.Equal(McpServer.MaxBatchRequestCount, limits["json_rpc_batch_max_requests"]!.GetValue()); + } + + [Fact] + public void ToolsList_BatchQuerySchemaAdvertisesLimitsAndControls_Issue3539() + { + var response = _server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!)!; + + var tools = response["result"]!["tools"]!.AsArray(); + var batchQuery = tools.First(tool => tool!["name"]!.GetValue() == "batch_query")!; + var properties = batchQuery["inputSchema"]!["properties"]!; + var queries = properties["queries"]!; + + Assert.Equal(1, queries["minItems"]!.GetValue()); + Assert.Equal(McpServer.MaxBatchQuerySize, queries["maxItems"]!.GetValue()); + var itemProperties = queries["items"]!["properties"]!; + Assert.Equal("string", itemProperties["id"]!["type"]!.GetValue()); + Assert.Equal("string", itemProperties["slotId"]!["type"]!.GetValue()); + Assert.Equal(McpServer.MaxBatchQueryResponseByteLimit, properties["maxResponseBytes"]!["maximum"]!.GetValue()); + Assert.False(properties["estimateOnly"]!["default"]!.GetValue()); } [Fact] @@ -6705,7 +6872,21 @@ public void ToolsList_ReferencesOffsetSchemaAdvertisesCap() } [Fact] - public void ToolsCall_References_ClampsTooLargeOffset() + public void ToolsList_MapDepthSchemaAdvertisesCap_Issue3436() + { + var response = _server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!)!; + + var tools = response["result"]!["tools"]!.AsArray(); + var map = tools.First(tool => tool!["name"]!.GetValue() == "map")!; + var depth = map["inputSchema"]!["properties"]!["depth"]!; + + Assert.Equal(0, depth["minimum"]!.GetValue()); + Assert.Equal(McpServer.MaxMcpMapDepth, depth["maximum"]!.GetValue()); + } + + [Fact] + public void ToolsCall_References_ClampsTooLargeOffset_Issue3436() { InsertIndexedFile( "src/offset-clamp.cs", @@ -6736,6 +6917,111 @@ public class OffsetClampCaller { public void Hit(App app) { app.Run(); } } var structured = response["result"]!["structuredContent"]!; Assert.Equal(McpServer.MaxMcpPaginationOffset, structured["offset"]!.GetValue()); Assert.True(structured["total"]!.GetValue() > 0); + var warning = Assert.Single(structured["warnings"]!.AsArray()); + Assert.Contains("offset was clamped", warning!.GetValue(), StringComparison.Ordinal); + var adjustment = Assert.Single(structured["argument_adjustments"]!.AsArray()); + Assert.Equal("offset", adjustment!["argument"]!.GetValue()); + Assert.Equal("clamped", adjustment["action"]!.GetValue()); + Assert.Equal(McpServer.MaxMcpPaginationOffset + 1, adjustment["requested"]!.GetValue()); + Assert.Equal(McpServer.MaxMcpPaginationOffset, adjustment["effective"]!.GetValue()); + Assert.Equal(0, adjustment["minimum"]!.GetValue()); + Assert.Equal(McpServer.MaxMcpPaginationOffset, adjustment["maximum"]!.GetValue()); + } + + [Fact] + public void ToolsCall_Search_ReportsClampedLimitAndSnippetLines_Issue3436() + { + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "search", + ["arguments"] = new JsonObject + { + ["query"] = "Run", + ["limit"] = 999, + ["snippetLines"] = 999, + }, + }, + }; + + var response = _server.HandleMessage(request)!; + + var structured = response["result"]!["structuredContent"]!; + Assert.Equal(SearchSnippetFormatter.MaxSnippetLines, structured["snippetLines"]!.GetValue()); + var warnings = structured["warnings"]!.AsArray().Select(warning => warning!.GetValue()).ToArray(); + Assert.Contains(warnings, warning => warning.Contains("limit was clamped", StringComparison.Ordinal)); + Assert.Contains(warnings, warning => warning.Contains("snippetLines was clamped", StringComparison.Ordinal)); + var adjustments = structured["argument_adjustments"]!.AsArray(); + var limit = adjustments.Single(adjustment => adjustment!["argument"]!.GetValue() == "limit")!; + Assert.Equal("clamped", limit["action"]!.GetValue()); + Assert.Equal(999, limit["requested"]!.GetValue()); + Assert.Equal(200, limit["effective"]!.GetValue()); + var snippetLines = adjustments.Single(adjustment => adjustment!["argument"]!.GetValue() == "snippetLines")!; + Assert.Equal("clamped", snippetLines["action"]!.GetValue()); + Assert.Equal(999, snippetLines["requested"]!.GetValue()); + Assert.Equal(SearchSnippetFormatter.MaxSnippetLines, snippetLines["effective"]!.GetValue()); + } + + [Fact] + public void ToolsCall_Map_ReportsClampedDepth_Issue3436() + { + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "map", + ["arguments"] = new JsonObject + { + ["depth"] = McpServer.MaxMcpMapDepth + 1, + }, + }, + }; + + var response = _server.HandleMessage(request)!; + + var structured = response["result"]!["structuredContent"]!; + Assert.Equal(McpServer.MaxMcpMapDepth, structured["depth"]!.GetValue()); + var adjustment = Assert.Single(structured["argument_adjustments"]!.AsArray()); + Assert.Equal("depth", adjustment!["argument"]!.GetValue()); + Assert.Equal("clamped", adjustment["action"]!.GetValue()); + Assert.Equal(McpServer.MaxMcpMapDepth + 1, adjustment["requested"]!.GetValue()); + Assert.Equal(McpServer.MaxMcpMapDepth, adjustment["effective"]!.GetValue()); + } + + [Fact] + public void ToolsCall_Map_ReportsIgnoredNegativeDepth_Issue3436() + { + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "map", + ["arguments"] = new JsonObject + { + ["depth"] = -1, + }, + }, + }; + + var response = _server.HandleMessage(request)!; + + var structured = response["result"]!["structuredContent"]!; + Assert.Null(structured["depth"]); + var adjustment = Assert.Single(structured["argument_adjustments"]!.AsArray()); + Assert.Equal("depth", adjustment!["argument"]!.GetValue()); + Assert.Equal("ignored", adjustment["action"]!.GetValue()); + Assert.Equal(-1, adjustment["requested"]!.GetValue()); + Assert.Null(adjustment["effective"]); } [Fact] @@ -6938,7 +7224,6 @@ public void ToolsCall_ExactAliases_RejectsCombinedFlags(string toolName, string } [Theory] - [InlineData("""{"names":""}""", "must be an array")] [InlineData("""{"names":[]}""", "no usable entries")] [InlineData("""{"names":[""]}""", "no usable entries")] [InlineData("""{"names":[" "]}""", "no usable entries")] @@ -6954,6 +7239,21 @@ public void ToolsCall_Symbols_RejectsMalformedOrEmptyNames(string argsJson, stri Assert.Contains(expectedMessageFragment, text); } + [Fact] + public void ToolsCall_Symbols_RejectsScalarNamesAsInvalidParams_Issue3538() + { + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"symbols","arguments":{"names":""}}}""")!; + var response = _server.HandleMessage(request)!; + + Assert.Null(response["result"]); + var error = response["error"]!; + Assert.Equal(-32602, error["code"]!.GetValue()); + Assert.Contains("Invalid type for argument 'names'", error["message"]!.GetValue(), StringComparison.Ordinal); + var data = error["data"]!; + Assert.Equal("names", data["parameter"]!.GetValue()); + Assert.Equal("array", data["expected"]!.GetValue()); + } + [Fact] public void ToolsCall_Symbols_FilterByKind() { @@ -8278,6 +8578,41 @@ public void ToolsCall_BatchQuery_IncludesPing() Assert.NotNull(results[0]!["result"]!["version"]); } + [Fact] + public void ToolsCall_BatchQuery_EchoesSlotIdAndSummary_Issue3539() + { + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"batch_query","arguments":{"queries":[{"slotId":"ping-slot","tool":"ping"}]}}}""")!; + var response = _server.HandleMessage(request)!; + + var slot = Assert.Single(response["result"]!["structuredContent"]!["results"]!.AsArray())!; + Assert.Equal("ping-slot", slot["slot_id"]!.GetValue()); + Assert.Equal("ping", slot["tool"]!.GetValue()); + Assert.True(slot["ok"]!.GetValue()); + Assert.Contains("cdidx v", slot["summary"]!.GetValue(), StringComparison.Ordinal); + Assert.NotNull(slot["result"]!["version"]); + } + + [Fact] + public void ToolsCall_BatchQuery_EstimateOnlyDoesNotExecuteSlots_Issue3539() + { + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"batch_query","arguments":{"estimateOnly":true,"queries":[{"id":"slot-a","tool":"ping"},{"slotId":"slot-b","tool":"search","arguments":{"query":"Run","limit":1}}]}}}""")!; + var response = _server.HandleMessage(request)!; + + var structured = response["result"]!["structuredContent"]!; + Assert.True(structured["estimate_only"]!.GetValue()); + Assert.Equal(2, structured["total_count"]!.GetValue()); + Assert.Equal(0, structured["metadata"]!["executed"]!.GetValue()); + Assert.Empty(structured["results"]!.AsArray()); + Assert.True(structured["metadata"]!["estimated_response_bytes"]!.GetValue() > 0); + var estimates = structured["slot_estimates"]!.AsArray(); + Assert.Equal(2, estimates.Count); + Assert.Equal("slot-a", estimates[0]!["slot_id"]!.GetValue()); + Assert.Equal("ping", estimates[0]!["tool"]!.GetValue()); + Assert.Equal("slot-b", estimates[1]!["slot_id"]!.GetValue()); + Assert.Equal("search", estimates[1]!["tool"]!.GetValue()); + Assert.Contains("query=\"Run\"", estimates[1]!["args_summary"]!.GetValue(), StringComparison.Ordinal); + } + [Fact] public void ToolsCall_BatchQuery_BlocksIndexInBatch() { @@ -8686,7 +9021,7 @@ public void ToolsCall_BatchQuery_TruncatesAggregateResponse_Issue1416() try { InsertIndexedFile("src/large.cs", "csharp", "// " + new string('x', 5000)); - var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"batch_query","arguments":{"queries":[{"tool":"ping"},{"tool":"excerpt","arguments":{"path":"src/large.cs","startLine":1,"endLine":1,"maxLineWidth":0}}]}}}""")!; + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"batch_query","arguments":{"queries":[{"slotId":"ping-slot","tool":"ping"},{"slotId":"excerpt-slot","tool":"excerpt","arguments":{"path":"src/large.cs","startLine":1,"endLine":1,"maxLineWidth":0}}]}}}""")!; var response = _server.HandleMessage(request)!; var structured = response["result"]!["structuredContent"]!; @@ -8696,7 +9031,8 @@ public void ToolsCall_BatchQuery_TruncatesAggregateResponse_Issue1416() Assert.True(structured["metadata"]!["estimated_response_bytes"]!.GetValue() <= 950); Assert.Equal(950, structured["metadata"]!["response_byte_limit"]!.GetValue()); Assert.Equal(2, structured["metadata"]!["submitted"]!.GetValue()); - Assert.Equal(2, structured["metadata"]!["executed"]!.GetValue()); + var executed = structured["metadata"]!["executed"]!.GetValue(); + Assert.InRange(executed, 1, 2); Assert.Equal(0, structured["metadata"]!["errors"]!.GetValue()); Assert.Equal("cascading", structured["failure_scope"]!.GetValue()); Assert.NotNull(structured["cascade_started_at_index"]); @@ -8705,9 +9041,17 @@ public void ToolsCall_BatchQuery_TruncatesAggregateResponse_Issue1416() var truncatedQueries = structured["truncated_queries"]!.AsArray(); Assert.NotEmpty(truncatedQueries); Assert.All(truncatedQueries, q => Assert.NotNull(q!["args_summary"])); + Assert.All(truncatedQueries, q => Assert.NotNull(q!["slot_id"])); Assert.Contains(truncatedQueries, q => - q!["tool"]?.GetValue() == "ping" && - q["reason"]?.GetValue() == "final_response_byte_limit_exceeded"); + q!["reason"]?.GetValue() is "response_byte_limit_exceeded" or "response_byte_limit_already_exceeded" or "final_response_byte_limit_exceeded"); + var splitHint = structured["split_hint"]!; + Assert.Equal("response_byte_limit_exceeded", splitHint["reason"]!.GetValue()); + var firstTruncatedRequestIndex = truncatedQueries + .Select(q => q!["request_index"]!.GetValue()) + .Min(); + Assert.Equal(firstTruncatedRequestIndex, splitHint["next_request_index"]!.GetValue()); + Assert.StartsWith("batch_query:v1:", splitHint["resume_cursor"]!.GetValue(), StringComparison.Ordinal); + Assert.True(splitHint["suggested_query_count"]!.GetValue() >= 1); var text = response["result"]!["content"]![0]!["text"]!.GetValue(); Assert.Contains("Response truncated", text); @@ -8731,6 +9075,40 @@ public void ToolsCall_BatchQuery_ClampsTooLargeResponseLimitEnvironment() Assert.Equal(McpServer.MaxBatchQueryResponseByteLimit, metadata["response_byte_limit"]!.GetValue()); } + [Fact] + public void ToolsCall_BatchQuery_UsesPerCallResponseBudget_Issue3539() + { + InsertIndexedFile("src/large-per-call.cs", "csharp", "// " + new string('x', 5000)); + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"batch_query","arguments":{"maxResponseBytes":1200,"queries":[{"slotId":"first","tool":"ping"},{"slotId":"second","tool":"excerpt","arguments":{"path":"src/large-per-call.cs","startLine":1,"endLine":1,"maxLineWidth":0}}]}}}""")!; + var response = _server.HandleMessage(request)!; + + var structured = response["result"]!["structuredContent"]!; + Assert.Equal(1200, structured["metadata"]!["response_byte_limit"]!.GetValue()); + Assert.True(structured["truncated"]!.GetValue(), response.ToJsonString()); + Assert.NotNull(structured["split_hint"]); + Assert.True(Encoding.UTF8.GetByteCount(response.ToJsonString()) <= 1200); + } + + [Fact] + public void ToolsCall_BatchQuery_ClampedPerCallBudgetCountsAdjustmentsAgainstBudget_Issue3539() + { + using var env = EnvironmentVariableScope.Capture("CDIDX_MCP_BATCH_RESPONSE_MAX_BYTES"); + env.Set("CDIDX_MCP_BATCH_RESPONSE_MAX_BYTES", "1400"); + InsertIndexedFile("src/large-per-call-clamped.cs", "csharp", "// " + new string('x', 5000)); + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"batch_query","arguments":{"maxResponseBytes":2000,"queries":[{"slotId":"first","tool":"ping"},{"slotId":"second","tool":"excerpt","arguments":{"path":"src/large-per-call-clamped.cs","startLine":1,"endLine":1,"maxLineWidth":0}}]}}}""")!; + + var response = _server.HandleMessage(request)!; + + var structured = response["result"]!["structuredContent"]!; + Assert.Equal(1400, structured["metadata"]!["response_byte_limit"]!.GetValue()); + Assert.True(Encoding.UTF8.GetByteCount(response.ToJsonString()) <= 1400, response.ToJsonString()); + var adjustment = Assert.Single(structured["argument_adjustments"]!.AsArray()); + Assert.Equal("maxResponseBytes", adjustment!["argument"]!.GetValue()); + Assert.Equal("clamped", adjustment["action"]!.GetValue()); + Assert.Equal(2000, adjustment["requested"]!.GetValue()); + Assert.Equal(1400, adjustment["effective"]!.GetValue()); + } + [Fact] public void ApplyExcerptOutputBudget_TruncatesAtLineBoundary_Issue1605() { diff --git a/tests/CodeIndex.Tests/McpToolContractTests.cs b/tests/CodeIndex.Tests/McpToolContractTests.cs index 066cde44e1..5f10a0507d 100644 --- a/tests/CodeIndex.Tests/McpToolContractTests.cs +++ b/tests/CodeIndex.Tests/McpToolContractTests.cs @@ -18,13 +18,6 @@ public class McpToolContractTests ("suggest_improvement", "evidence_paths"), ]; - private static readonly HashSet SpecializedListValidatedArguments = new(StringComparer.Ordinal) - { - "excludePaths", - "names", - "sections", - }; - [Fact] public void ToolsList_AdvertisedInputPropertiesMatchArgumentAllowlist_Issue3199() { @@ -73,18 +66,6 @@ public void ToolsList_AdvertisedInputPropertiesHaveMatchingTypeValidation_Issue3 continue; } - if (SpecializedListValidatedArguments.Contains(argumentName)) - { - if (schemaType != "array") - { - failures.Add( - $"{toolName}.{argumentName}: schema={schemaType}; " - + "specialized_list_validator=array"); - } - - continue; - } - var (hasValidator, validatorType) = TryGetExpectedJsonType(toolName, argumentName); if (!hasValidator || validatorType != schemaType) { @@ -148,8 +129,8 @@ public void ToolsList_MapSectionsAndDepthHaveSharedArgumentContract_Issue3197() Assert.True(mapProperties.ContainsKey("sections")); Assert.Contains("sections", allowed); Assert.Equal("array", ExpectedTypeFromSchema(mapProperties["sections"])); - Assert.Contains("sections", SpecializedListValidatedArguments); - Assert.Equal((false, string.Empty), TryGetExpectedJsonType("map", "sections")); + Assert.Equal("array", mapProperties["sections"]["x-expectedType"]!.GetValue()); + Assert.Equal((true, "array"), TryGetExpectedJsonType("map", "sections")); Assert.True(mapProperties.ContainsKey("depth")); Assert.Contains("depth", allowed); @@ -157,6 +138,38 @@ public void ToolsList_MapSectionsAndDepthHaveSharedArgumentContract_Issue3197() Assert.Equal((true, "integer"), TryGetExpectedJsonType("map", "depth")); } + [Fact] + public void ToolsList_CommonListAndAliasMetadata_AreAdvertised_Issue3538() + { + var advertisedSchemas = GetAdvertisedToolSchemas(); + + foreach (var toolName in new[] { "search", "definition", "references", "callers", "callees", "symbols", "files", "map", "analyze_symbol", "deps", "impact_analysis", "validate", "unused_symbols", "symbol_hotspots" }) + { + var excludePaths = advertisedSchemas[toolName]["excludePaths"]; + Assert.Equal("string_or_array", ExpectedTypeFromSchema(excludePaths)); + Assert.Equal("string_or_array", excludePaths["x-expectedType"]!.GetValue()); + Assert.Equal((true, "string_or_array"), TryGetExpectedJsonType(toolName, "excludePaths")); + } + + var symbols = advertisedSchemas["symbols"]; + Assert.Equal("array", ExpectedTypeFromSchema(symbols["names"])); + Assert.Equal("array", symbols["names"]["x-expectedType"]!.GetValue()); + Assert.Equal((true, "array"), TryGetExpectedJsonType("symbols", "names")); + + var references = advertisedSchemas["references"]; + Assert.Equal("boolean", ExpectedTypeFromSchema(references["lspCompatible"])); + Assert.Equal("boolean", references["lspCompatible"]["x-expectedType"]!.GetValue()); + Assert.Equal("lsp_compatible", references["lspCompatible"]["x-aliasOf"]!.GetValue()); + Assert.Contains("lspCompatible", references["lsp_compatible"]["x-aliases"]!.AsArray().Select(alias => alias!.GetValue())); + + Assert.True(references["exact"]["deprecated"]!.GetValue()); + Assert.Equal("exactName", references["exact"]["x-aliasOf"]!.GetValue()); + + var impact = advertisedSchemas["impact_analysis"]; + Assert.True(impact["maxDepth"]["deprecated"]!.GetValue()); + Assert.Equal("maxHops", impact["maxDepth"]["x-aliasOf"]!.GetValue()); + } + [Fact] public void ToolsList_OutlineAndValidateDoNotExposeHiddenNoopArguments_Issue3198() {