From efb8b412fe1033ed51f3cc384d76a43688c9da7b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 31 Jul 2026 09:29:15 +0900 Subject: [PATCH 1/2] Add typed MCP resource reads (#4900) --- DEVELOPER_GUIDE.md | 2 + USER_GUIDE.md | 27 +++ changelog.d/unreleased/4900.added.md | 28 +++ src/CodeIndex/Mcp/McpServer.Resources.cs | 122 +++++++++++-- src/CodeIndex/Mcp/McpServer.Responses.cs | 3 +- src/CodeIndex/Mcp/McpServer.ToolDispatch.cs | 1 + src/CodeIndex/Mcp/McpServer.cs | 4 +- src/CodeIndex/Mcp/McpToolArgumentContracts.cs | 3 +- src/CodeIndex/Mcp/McpToolCatalog.cs | 70 ++++++++ src/CodeIndex/Mcp/McpToolFilter.cs | 1 + .../Mcp/McpToolHandlers.ArgumentValidation.cs | 25 ++- .../Mcp/McpToolHandlers.Instructions.cs | 12 +- .../CodeIndex.Tests/McpServerProtocolTests.cs | 2 + tests/CodeIndex.Tests/McpServerTests.cs | 168 ++++++++++++++++++ .../McpServerToolsListTests.cs | 15 +- 15 files changed, 456 insertions(+), 27 deletions(-) create mode 100644 changelog.d/unreleased/4900.added.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 33da20289f..abf6720ee9 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -2041,6 +2041,7 @@ Process exit codes are coarse (`0` success including valid zero-row queries, `1` - **MCP resource-list cursor stability** — `resources/list` emits a fixed-size opaque keyset cursor that binds the last consumed file id to a persisted indexed-file generation and the canonical discovery filters. The reader resolves that id back to the existing source/test/docs bucket plus path ordering inside the same SQLite snapshot. Any file insertion, deletion, or update changes the generation; a later page then returns `-32011` / `index_stale` with `restart_required: true`. Changing `path`, `lang`, or `includeGenerated` between pages returns `-32602` / `resources_list_filters_changed` with the same restart requirement. In either case, the client must omit `params.cursor` to restart instead of continuing across mixed snapshots or filters. Writable legacy databases install the generation row and triggers through the normal read migration before a cursor is issued. A mutable read-only legacy database that cannot prove generation tracking returns `resources_list_generation_unavailable` with `migration_required: true`; a canonical, unambiguous `immutable=1` legacy URI (optionally paired with `mode=ro`) may safely use connection-local generation zero because it cannot change between pages. Encoded, case-variant, whitespace-padded, duplicated, conflicting, or extra query parameters are not trusted as that immutable guarantee. The legacy decimal zero remains a first-page upgrade input, but nonzero decimal offsets cannot prove their source generation and therefore return the same restart-required error; decimal cursors are never emitted. Version-1 opaque cursors remain valid only with the default unfiltered view. - **MCP file resource discovery** — `resources/templates/list` advertises `cdidx://file-path/{path}` so a client that already knows an exact repository-relative path can construct a `resources/read` URI without paging the repository inventory. Simple URI-template expansion percent-encodes separators and reserved filename characters such as `?` and `#`; the template-only resolver decodes the value once, rejects absolute paths, traversal, backslashes, empty segments, queries, and fragments, then returns the canonical `cdidx://file/` identity. Canonical resource URIs continue to reject encoded separators. `resources/list` accepts `path` as one string or at most 100 strings of at most 1024 characters and 128 wildcard operators each, using the same anchored directory/glob semantics as file queries, plus an exact normalized `lang` filter and `includeGenerated` (default `false`). Generated files also require `includeGenerated: true` for direct reads. - **MCP resource-list response budget** — `resources/list.params.maxBytes` accepts 4,096 through 1,000,000 bytes and defaults to 1,000,000, matching the default HTTP response-body cap. The effective budget is the minimum of that request, the server-wide MCP envelope cap, and the active HTTP transport response-body cap (when applicable), so a lower configured HTTP cap shapes a valid page instead of rejecting it with HTTP 500. The server measures the complete JSON-RPC envelope, keeps 200 as the candidate ceiling, and stops before the next resource would cross the effective byte budget. For HTTP JSON-RPC batches, the active transport budget covers the complete response array, including brackets and commas, and is divided fairly among response-bearing items; notifications consume no response slot. Each `resources/list` item honors its current share and preserves its request ID in a canonical budget error if even a bounded page cannot fit. State-changing and other non-resource outcomes are never relabeled as retry-safe; an aggregate overflow after execution reports an unknown completion state and forbids automatic retry. `_meta.response_controls` reports the requested/effective budgets, consumed and returned counts, `omitted_resource_count`, bounded reason counts (`resource_uri_too_long` / `resource_exceeds_max_bytes`), `byte_budget_reached`, and `continuation_reason` (`byte_budget`, `item_limit`, or `completed`). A continuation cursor anchors the last consumed database row; a valid resource that did not fit remains unconsumed for the next page, while a resource that cannot fit even on an empty page is consumed and counted so pagination cannot livelock. +- **Typed MCP resource reads** — `read_resource` is the discoverable tools/call surface for bounded file reads. Its `tools/list` schema owns the typed `uri`, 1-based inclusive line range, 4-byte through 128-KiB UTF-8 text budget, generated-file opt-in, and opaque continuation cursor contract. The handler delegates to `HandleResourcesRead`, so URI validation, generated-file policy, snapshot lookup, response-aware budgeting, UTF-8 boundaries, stale-cursor checks, and legacy index compatibility stay in one reader. The adapter returns file text once in `content[0].text`, puts canonical identity in `structuredContent.resource`, and puts range/truncation/continuation data in `structuredContent._meta`; resource errors become MCP tool-result errors with the original classification and JSON-RPC code preserved. Keep `resources/read` and both URI forms backward compatible. Clients must feature-detect `read_resource` in `tools/list` so older servers continue through the protocol method. - **MCP array argument bounds** — MCP string-array filters such as `path`, `project`, `excludePaths`, and mixed `names` arrays reject invalid entries instead of silently dropping them. Arrays are capped at 100 entries and each entry is capped at 4096 characters; `batch_query` reports these validation failures per slot with `request_index` and `ok: false`. - **MCP schema lock-down** — Every tool `inputSchema` includes `additionalProperties: false`, and `tools/call` mirrors that contract by rejecting unknown argument names with `-32602` / `invalid_argument` instead of silently defaulting misspelled fields. - **MCP stability markers and naming** — Every tool advertises `x-stability` (`stable`, `experimental`, or `deprecated`). MCP structured payload keys use snake_case, matching the CLI JSON contract; do not add camelCase aliases for new fields. @@ -5470,6 +5471,7 @@ USER_GUIDEの[終了コード](USER_GUIDE.md#終了コード)セクションを - **MCP ファイル resource discovery** — `resources/templates/list` は `cdidx://file-path/{path}` を公開し、正確なリポジトリ相対 path が既知の client は全 inventory をページングせず `resources/read` URI を構築できる。simple URI-template expansion は separator と `?` / `#` などの予約 filename 文字を percent encode する。template 専用 resolver は値を一度だけ decode し、absolute path、traversal、backslash、空 segment、query、fragment を拒否して canonical な `cdidx://file/` identity を返す。canonical resource URI は encoded separator を引き続き拒否する。`resources/list` は `path` に 1 文字列または各 1024 文字・wildcard operator 128 個以内かつ最大 100 件の文字列を受け付け、file query と同じ anchored directory / glob semantics を使うほか、正規化した完全一致の `lang` と `includeGenerated`(既定 `false`)も受け付ける。cursor は generation と canonical filter の両方に結び付き、ページ間の filter 変更は `-32602` / `resources_list_filters_changed` と `restart_required: true` を返す。generated file は discovery と direct read のどちらでも `includeGenerated: true` が必要である。 - **MCP resource list カーソルの安定性** — `resources/list` は、最後に消費した file id と永続化されたインデックス済みファイル世代を結び付ける固定長の不透明 keyset cursor を返す。reader は同じ SQLite snapshot 内でその id を既存の source/test/docs bucket と path の並び順へ解決する。ファイルの追加・削除・更新で世代が変わると、後続ページは `restart_required: true` 付きの `-32011` / `index_stale` を返し、混在 snapshot を続行せず `params.cursor` を省略して再開する必要がある。書き込み可能な legacy DB は cursor 発行前に通常の read migration で世代 row と trigger を導入する。世代追跡を証明できない変更可能な read-only legacy DB は `migration_required: true` 付きの `resources_list_generation_unavailable` を返すが、canonical かつ曖昧性のない `immutable=1` legacy URI(任意で `mode=ro` を併記)はページ間で変化しないため connection-local な世代 0 を安全に利用できる。encoded、case variant、空白付き、重複、競合、または余分な query parameter はこの immutable 保証として信頼しない。旧 decimal の 0 は先頭ページ用の移行入力として残すが、0 以外の decimal offset は発行時世代を検証できないため同じ再開必須 error を返し、decimal cursor は出力に使わない。 - **MCP resource list の response budget** — `resources/list.params.maxBytes` は 4,096〜1,000,000 bytes を受け付け、既定値は HTTP response body の既定上限と同じ 1,000,000。有効 budget はこの要求値、server-wide MCP envelope 上限、および該当時の active HTTP transport response-body 上限の最小値とし、HTTP の設定上限が低い場合も HTTP 500 で拒否せず有効なページへ整形する。server は JSON-RPC envelope 全体を計測し、candidate 上限を 200 件に保ったまま、次の resource で有効 byte budget を超える直前に停止する。HTTP JSON-RPC batch では active transport budget を bracket と comma を含む response 配列全体へ適用し、応答対象 item へ公平に分配する。notification は response slot を消費しない。各 `resources/list` item は現在の割当を守り、有界なページさえ収まらない場合も canonical な budget error に request ID を保持する。state-changing item とその他の non-resource outcome を retry-safe として付け替えることはなく、実行後の aggregate overflow は completion state が unknown で自動再試行不可であることを報告する。`_meta.response_controls` は要求/有効 budget、消費/返却件数、`omitted_resource_count`、有界な理由別件数(`resource_uri_too_long` / `resource_exceeds_max_bytes`)、`byte_budget_reached`、`continuation_reason`(`byte_budget`、`item_limit`、`completed`)を返す。継続 cursor は最後に消費した DB row を anchor とし、収まらなかった有効 resource は次ページ用に未消費のまま残すが、空ページにも収まらない resource は消費して件数へ計上し、pagination の livelock を防ぐ。 +- **型付き MCP resource read** — `read_resource` は bounded file read を発見可能にする tools/call surface である。`tools/list` schema が型付き `uri`、1-based inclusive の行範囲、4 byte〜128 KiB の UTF-8 text budget、generated file の opt-in、opaque な continuation cursor 契約を持つ。handler は `HandleResourcesRead` に委譲するため、URI validation、generated-file policy、snapshot lookup、response-aware budgeting、UTF-8 boundary、stale cursor check、legacy index 互換性は 1 つの reader に保たれる。adapter は file text を `content[0].text` に一度だけ返し、canonical identity を `structuredContent.resource`、範囲・切り詰め・継続 metadata を `structuredContent._meta` に置く。resource error は元の分類と JSON-RPC code を維持した MCP tool-result error へ変換する。`resources/read` と両 URI 形式の後方互換性を維持し、client は `tools/list` で `read_resource` を feature-detect して、古い server では protocol method を使い続けること。 - **MCP 配列引数の上限** — `path` / `project` / `excludePaths` / mixed `names` などの string-array filter は、不正要素を暗黙に落とさず拒否する。配列は 100 件、各要素は 4096 文字を上限とし、`batch_query` では `request_index` と `ok: false` 付きの slot 失敗として報告する。 - **MCP schema のロックダウン** — すべての tool `inputSchema` は `additionalProperties: false` を含み、`tools/call` も同じ契約として未知の引数名を黙って既定値にせず `-32602` / `invalid_argument` で拒否する。 - **MCP stability marker と命名** — すべての tool は `x-stability`(`stable`、`experimental`、`deprecated`)を公開する。MCP の構造化 payload key は CLI JSON 契約に合わせて snake_case を使う。新規 field に camelCase alias を追加しないこと。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 8db38f3bc7..e26c2b44af 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2789,6 +2789,20 @@ parameters and bounds under `_meta.discovery_contract`, so AI clients can discov `path`, `lang`, `includeGenerated`, `maxBytes`, and cursor semantics without guessing beyond the standard protocol. +Clients that need typed discovery should prefer the `read_resource` tool when +`tools/list` advertises it. Its `inputSchema` declares the required `uri` plus +optional 1-based inclusive `startLine` / `endLine`, UTF-8 `maxBytes`, +`includeGenerated`, and opaque continuation `cursor`, including their ranges and +mutual-exclusion rules. The file text is returned in `content[0].text`; +`structuredContent.resource` carries its canonical identity and +`structuredContent._meta` carries effective ranges, byte counts, truncation, and +`nextCursor`. `read_resource` and `resources/read` use the same validation and +bounded database reader. Existing `cdidx://file/...` and +`cdidx://file-path/...` URIs do not change. Clients should feature-detect +`read_resource` through `tools/list`; older servers and compatibility-oriented +clients can continue to use `resources/read`, whose extra range/budget fields +remain supported even though the standard MCP method only types `uri`. + MCP `resources/read` is bounded too. Pass optional inclusive `startLine` / `endLine` values and `maxBytes` (the UTF-8 resource-text budget); the minimum is 4 bytes, omitted budgets default to 64 KiB, and requests cannot exceed 128 KiB. Each page is also capped at 1,000 logical lines. `result._meta.truncationReason` is `maxLines` when that cap is reached and `maxBytes` when the requested text budget is reached. If the configured MCP or HTTP response ceiling is tighter, `_meta.maxBytes` retains the requested budget, `_meta.effectiveMaxBytes` reports the reduced budget, and truncation uses `maxResponseBytes`. Multiple `resources/read` calls in one JSON-RPC batch share the aggregate frame ceiling, so each item also yields to the space remaining in that batch. A non-pageable item that cannot fit its allocation returns a structured `batch_response_budget_too_small` error while preserving the request ID. When `result._meta.truncated` is true, send the returned `nextCursor` with the same resource URI to continue. Do not combine a cursor with new line boundaries; you may change `maxBytes` for the next page. A cursor becomes stale if the indexed resource changes. Read-only or immutable legacy databases without the dedicated range indexes use the existing `idx_chunks_file` index for a metadata-only compatibility lookup under a SQLite VM-step budget; exceeding that budget returns a structured index-unavailable error instead of performing an unbounded scan. ### MCP Server (for Claude Code, Cursor, Windsurf, etc.) @@ -6032,6 +6046,19 @@ parameter と上限を `_meta.discovery_contract` に公開するため、AI cli protocol の外側にある `path`、`lang`、`includeGenerated`、`maxBytes`、cursor semantics を推測せず発見できます。 +型付き discovery が必要な client は、`tools/list` に公開されている場合は +`read_resource` tool を優先してください。その `inputSchema` は必須の `uri` と、 +任意の 1-based inclusive な `startLine` / `endLine`、UTF-8 `maxBytes`、 +`includeGenerated`、opaque な継続 `cursor` を、範囲・排他規則とともに宣言します。 +file text は `content[0].text`、canonical identity は +`structuredContent.resource`、有効範囲・byte 数・切り詰め・`nextCursor` は +`structuredContent._meta` に返ります。`read_resource` と `resources/read` は +同じ validation と bounded database reader を使用し、既存の +`cdidx://file/...` / `cdidx://file-path/...` URI は変わりません。client は +`tools/list` で `read_resource` を feature-detect してください。古い server や +互換性重視の client は引き続き `resources/read` を使用でき、標準 MCP method が +`uri` だけを型付けしていても追加の range / budget field は維持されます。 + MCP `resources/read` にも上限があります。inclusive な `startLine` / `endLine` と、UTF-8 resource 本文の budget である `maxBytes` を任意指定できます。最小値は 4 byte、budget 省略時は 64 KiB、要求可能な最大値は 128 KiB です。各ページには論理行 1,000 行の上限もあります。この上限に達した場合、`result._meta.truncationReason` は `maxLines`、要求した本文 budget に達した場合は `maxBytes` になります。設定された MCP または HTTP のレスポンス上限の方が小さい場合、`_meta.maxBytes` は要求値を保持し、`_meta.effectiveMaxBytes` が縮小後の budget を示し、切り詰め理由は `maxResponseBytes` になります。1 つの JSON-RPC batch に複数の `resources/read` call がある場合は aggregate frame 上限を共有するため、各 item はその batch の残り領域にも従います。page 化できない item が割当内に収まらない場合は、request ID を保持した構造化 `batch_response_budget_too_small` error を返します。`result._meta.truncated` が true の場合、返された `nextCursor` を同じ resource URI とともに送って継続してください。cursor と新しい行境界は併用できませんが、次ページの `maxBytes` は変更できます。index 済み resource が変わると cursor は stale になります。専用の range index がない read-only または immutable な legacy database では、既存の `idx_chunks_file` index を使い、SQLite VM-step budget 内の metadata-only compatibility lookup を行います。この budget を超えた場合は無制限に scan せず、構造化された index-unavailable error を返します。 ### MCP サーバー(Claude Code、Cursor、Windsurf 等に対応) diff --git a/changelog.d/unreleased/4900.added.md b/changelog.d/unreleased/4900.added.md new file mode 100644 index 0000000000..f1760a9880 --- /dev/null +++ b/changelog.d/unreleased/4900.added.md @@ -0,0 +1,28 @@ +--- +category: added +issues: + - 4900 +affected: + - src/CodeIndex/Mcp/McpServer.Resources.cs + - src/CodeIndex/Mcp/McpServer.Responses.cs + - src/CodeIndex/Mcp/McpServer.ToolDispatch.cs + - src/CodeIndex/Mcp/McpServer.cs + - src/CodeIndex/Mcp/McpToolArgumentContracts.cs + - src/CodeIndex/Mcp/McpToolCatalog.cs + - src/CodeIndex/Mcp/McpToolFilter.cs + - src/CodeIndex/Mcp/McpToolHandlers.ArgumentValidation.cs + - src/CodeIndex/Mcp/McpToolHandlers.Instructions.cs + - tests/CodeIndex.Tests/McpServerProtocolTests.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - tests/CodeIndex.Tests/McpServerToolsListTests.cs + - USER_GUIDE.md + - DEVELOPER_GUIDE.md +--- + +## English + +- **Bounded MCP resource reads are now discoverable as a typed tool (#4900)** — `tools/list` advertises `read_resource` with typed URI, inclusive line-range, UTF-8 byte-budget, generated-file, and continuation-cursor inputs while preserving the existing resource URIs and `resources/read` compatibility through the same bounded reader. + +## 日本語 + +- **上限付き MCP resource read を型付き tool として発見できるようになりました (#4900)** — `tools/list` は URI、inclusive な行範囲、UTF-8 byte budget、generated file、continuation cursor を型付けした `read_resource` を公開し、同じ bounded reader を通じて既存の resource URI と `resources/read` の互換性を維持します。 diff --git a/src/CodeIndex/Mcp/McpServer.Resources.cs b/src/CodeIndex/Mcp/McpServer.Resources.cs index 1020285350..7e911fd65f 100644 --- a/src/CodeIndex/Mcp/McpServer.Resources.cs +++ b/src/CodeIndex/Mcp/McpServer.Resources.cs @@ -730,7 +730,10 @@ private readonly record struct ResourceListCursor( ulong FilterFingerprint, bool HasFilterFingerprint); - private JsonNode HandleResourcesRead(JsonNode? id, JsonNode? readParams) + private JsonNode HandleResourcesRead( + JsonNode? id, + JsonNode? readParams, + bool adaptForToolResult = false) { if (readParams is not null && readParams is not JsonObject) { @@ -869,7 +872,8 @@ private JsonNode HandleResourcesRead(JsonNode? id, JsonNode? readParams) id, resourceUri, mimeType, - maxBytes); + maxBytes, + adaptForToolResult); if (effectiveMaxBytes < MinResourceReadMaxBytes) return CreateErrorResponse(hasId: true, id: id, code: -32603, message: "The configured MCP response limit is too small for a resources/read page.", @@ -959,6 +963,75 @@ private JsonNode HandleResourcesRead(JsonNode? id, JsonNode? readParams) })); } + /// + /// Adapt the backward-compatible resources/read implementation to a typed tools/call + /// result without duplicating file text in structuredContent. Validation, index access, + /// UTF-8 paging, and continuation state stay owned by the single resource reader. + /// 後方互換の resources/read 実装を型付き tools/call result へ変換し、 + /// structuredContent には file text を重複させない。validation、index access、 + /// UTF-8 paging、continuation state は単一の resource reader が引き続き担当する。 + /// + private JsonNode ExecuteReadResource(JsonNode? id, JsonNode? args) + { + var resourceResponse = HandleResourcesRead(id, args, adaptForToolResult: true); + if (resourceResponse["error"] is JsonObject error) + { + var errorData = error["data"] as JsonObject; + var category = TryReadStringValue(errorData?["category"]) + ?? McpErrorEnvelope.CategoryInvalidArgument; + var suggestion = TryReadStringValue(errorData?["suggestion"]) + ?? "Inspect the read_resource inputSchema via tools/list and correct the URI, range, budget, or cursor."; + var retrySafe = errorData?["retry_safe"] is JsonValue retrySafeValue + && retrySafeValue.TryGetValue(out var parsedRetrySafe) + && parsedRetrySafe; + var extraData = errorData?.DeepClone().AsObject() ?? new JsonObject(); + extraData.Remove("category"); + extraData.Remove("suggestion"); + extraData.Remove("retry_safe"); + if (error["code"] is JsonNode errorCode) + extraData["jsonrpc_code"] = errorCode.DeepClone(); + + return CreateToolErrorResponse( + id, + TryReadStringValue(error["message"]) ?? "read_resource failed.", + category, + suggestion, + retrySafe, + extraData); + } + + if (resourceResponse["result"] is not JsonObject resourceResult + || resourceResult["contents"] is not JsonArray contents + || contents.Count != 1 + || contents[0] is not JsonObject content) + { + return CreateToolErrorResponse( + id, + "read_resource received an invalid internal resource response.", + McpErrorEnvelope.CategoryInternalError, + "Retry once. If the problem persists, rebuild the index and report the server diagnostics.", + retrySafe: true); + } + + var text = TryReadStringValue(content["text"]) ?? string.Empty; + var mimeType = TryReadStringValue(content["mimeType"]) ?? "text/plain"; + var structuredContent = new JsonObject + { + ["resource"] = new JsonObject + { + ["uri"] = content["uri"]?.DeepClone(), + ["mimeType"] = mimeType, + }, + ["_meta"] = resourceResult["_meta"]?.DeepClone(), + }; + return CreateToolResult( + id, + text, + structuredContent, + mimeType, + enrichStructuredContent: false); + } + private JsonObject CreateResourceReadStorageError( JsonNode? id, BoundedFileReadStatus status, @@ -1010,7 +1083,8 @@ private int GetEffectiveResourceReadMaxBytes( JsonNode? id, string resourceUri, string mimeType, - int requestedMaxBytes) + int requestedMaxBytes, + bool adaptForToolResult) { var worstCaseMetadata = new JsonObject { @@ -1030,19 +1104,41 @@ private int GetEffectiveResourceReadMaxBytes( ["nextLineByteOffset"] = int.MaxValue, ["nextCursor"] = new string('x', MaxResourceReadCursorCharacters), }; - var worstCaseResponse = CreateSuccessResponse(true, id, new JsonObject - { - ["contents"] = new JsonArray + var worstCaseResponse = adaptForToolResult + ? CreateSuccessResponse(true, id, new JsonObject { - new JsonObject + ["content"] = new JsonArray { - ["uri"] = resourceUri, - ["mimeType"] = mimeType, - ["text"] = string.Empty, + new JsonObject + { + ["type"] = "text", + ["mimeType"] = mimeType, + ["text"] = string.Empty, + }, }, - }, - ["_meta"] = worstCaseMetadata, - }); + ["structuredContent"] = new JsonObject + { + ["resource"] = new JsonObject + { + ["uri"] = resourceUri, + ["mimeType"] = mimeType, + }, + ["_meta"] = worstCaseMetadata, + }, + }) + : CreateSuccessResponse(true, id, new JsonObject + { + ["contents"] = new JsonArray + { + new JsonObject + { + ["uri"] = resourceUri, + ["mimeType"] = mimeType, + ["text"] = string.Empty, + }, + }, + ["_meta"] = worstCaseMetadata, + }); var envelopeBytes = Encoding.UTF8.GetByteCount(worstCaseResponse.ToJsonString(_jsonOptions)); var availableEncodedTextBytes = GetEffectiveResourceReadResponseLimit() - envelopeBytes; if (availableEncodedTextBytes <= 0) diff --git a/src/CodeIndex/Mcp/McpServer.Responses.cs b/src/CodeIndex/Mcp/McpServer.Responses.cs index 0551e92fd0..c19a627c47 100644 --- a/src/CodeIndex/Mcp/McpServer.Responses.cs +++ b/src/CodeIndex/Mcp/McpServer.Responses.cs @@ -529,6 +529,7 @@ private static JsonArray BuildToolExamples(string name) "symbols" => new JsonObject { ["query"] = "App", ["kind"] = "class" }, "files" => new JsonObject { ["query"] = "app.cs", ["lang"] = "csharp" }, "excerpt" => new JsonObject { ["path"] = "src/app.cs", ["startLine"] = 1, ["endLine"] = 5 }, + "read_resource" => new JsonObject { ["uri"] = "cdidx://file/src/app.cs", ["startLine"] = 1, ["endLine"] = 5 }, "find_in_file" => new JsonObject { ["path"] = "src/app.cs", ["query"] = "Run", ["before"] = 1, ["after"] = 1 }, "map" => new JsonObject { ["limit"] = 5, ["excludeTests"] = true }, "analyze_symbol" => new JsonObject { ["query"] = "Run", ["includeBody"] = true }, @@ -590,7 +591,7 @@ private static string AppendLanguageSupportClause(string name, string descriptio => "Language support: Supports indexed file/content filters for every detected language; call `languages` for the full catalog.", "find_in_file" or "files" or "map" => $"Language support: Supports indexed file/content filters for every detected language listed by `languages`: {DetectedLanguageList()}. Symbol and graph fields are available only for the languages whose capabilities are advertised by `languages`.", - "excerpt" or "status" or "validate" + "excerpt" or "read_resource" or "status" or "validate" => $"Language support: Language-agnostic over indexed files and diagnostics for every detected language listed by `languages`: {DetectedLanguageList()}. This tool does not interpret a `lang` filter.", "languages" => "Language support: This is the authoritative language catalog for MCP tools; it lists every detected language plus symbol_extraction, reference_extraction, graph_queries, and capability_gaps fields.", diff --git a/src/CodeIndex/Mcp/McpServer.ToolDispatch.cs b/src/CodeIndex/Mcp/McpServer.ToolDispatch.cs index 250392d8fc..49459ca3e4 100644 --- a/src/CodeIndex/Mcp/McpServer.ToolDispatch.cs +++ b/src/CodeIndex/Mcp/McpServer.ToolDispatch.cs @@ -250,6 +250,7 @@ private async Task DispatchToolCallAsync( "files" => ExecuteFiles(id, args), "find_in_file" => ExecuteFindInFile(id, args), "excerpt" => ExecuteExcerpt(id, args), + "read_resource" => ExecuteReadResource(id, args), "map" => ExecuteMap(id, args), "analyze_symbol" => ExecuteAnalyzeSymbol(id, args), "status" => ExecuteStatus(id, args), diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index f4ab994316..ca63050209 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -244,8 +244,8 @@ public partial class McpServer : IDisposable internal const int MaxResourceReadMaxBytes = 128 * 1024; internal const int MaxResourceReadLinesPerPage = 1_000; internal const int MaxResourceReadCursorCharacters = 128; - internal const int DefaultToolsListPageSize = 24; - internal const int MaxToolsListPageSize = 24; + internal const int DefaultToolsListPageSize = 25; + internal const int MaxToolsListPageSize = 25; internal const int MaxToolsListNameFilters = 24; internal const int MaxToolsListNameCharacters = 128; internal const int MaxToolsListCursorCharacters = 8_192; diff --git a/src/CodeIndex/Mcp/McpToolArgumentContracts.cs b/src/CodeIndex/Mcp/McpToolArgumentContracts.cs index b5ac9e5857..0f4f97f449 100644 --- a/src/CodeIndex/Mcp/McpToolArgumentContracts.cs +++ b/src/CodeIndex/Mcp/McpToolArgumentContracts.cs @@ -5,7 +5,7 @@ public partial class McpServer private static bool IsKnownToolName(string toolName) => toolName switch { "search" or "definition" or "references" or "callers" or "callees" or "symbols" or - "files" or "find_in_file" or "excerpt" or "map" or "analyze_symbol" or "status" or + "files" or "find_in_file" or "excerpt" or "read_resource" or "map" or "analyze_symbol" or "status" or "outline" or "batch_query" or "deps" or "impact_analysis" or "languages" or "validate" or "unused_symbols" or "symbol_hotspots" or "ping" or "index" or "backfill_fold" or "suggest_improvement" => true, @@ -22,6 +22,7 @@ public partial class McpServer "files" => new HashSet(StringComparer.Ordinal) { "query", "lang", "limit", "cursor", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "orderBySize", "rawBytes", "project", "solution" }, "find_in_file" => new HashSet(StringComparer.Ordinal) { "query", "path", "limit", "lang", "excludePaths", "excludeTests", "includeGenerated", "before", "after", "snippetLines", "focusLine", "focusColumn", "maxLineWidth", "exact", "regex" }, "excerpt" => new HashSet(StringComparer.Ordinal) { "path", "startLine", "endLine", "before", "after", "focusLine", "focusColumn", "focusLength", "maxLineWidth", "maxOutputBytes" }, + "read_resource" => new HashSet(StringComparer.Ordinal) { "uri", "startLine", "endLine", "maxBytes", "cursor", "includeGenerated" }, "map" => new HashSet(StringComparer.Ordinal) { "limit", "lang", "path", "excludePaths", "excludeTests", "sections", "depth", "minEntrypointConfidence", "project", "solution" }, "analyze_symbol" => new HashSet(StringComparer.Ordinal) { "query", "lang", "limit", "cursor", "includeBody", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "maxLineWidth", "countOnly", "format", "project", "solution" }, "status" => new HashSet(StringComparer.Ordinal) { "check", "scopes", "staleAfterSeconds", "explain", "config", "logPath", "updateCheck", "format", "fields" }, diff --git a/src/CodeIndex/Mcp/McpToolCatalog.cs b/src/CodeIndex/Mcp/McpToolCatalog.cs index 12d6b0a75a..0b08a0c0d8 100644 --- a/src/CodeIndex/Mcp/McpToolCatalog.cs +++ b/src/CodeIndex/Mcp/McpToolCatalog.cs @@ -251,6 +251,76 @@ private static JsonArray CreateToolCatalog() ["required"] = new JsonArray { "path", "startLine" } }, ReadOnlyAnnotations()), + CreateToolDefinition( + "read_resource", + "Use this typed tool when an exact `cdidx://file/...` or `cdidx://file-path/...` URI is known and a bounded full or ranged file read is needed. The file text is returned in `content[0].text`; `structuredContent.resource` identifies it and `structuredContent._meta` reports the effective range, UTF-8 byte count, truncation, and opaque `nextCursor`. Continue with `cursor` and optional `maxBytes`, without line boundaries. This tool and `resources/read` share the same validated bounded reader; the protocol method remains available for compatibility. / 正確な `cdidx://file/...` または `cdidx://file-path/...` URI が分かっており、ファイル全体または行範囲を上限付きで読む場合に使う型付き tool。テキストは `content[0].text`、resource identity は `structuredContent.resource`、有効範囲・UTF-8 byte 数・切り詰め・opaque `nextCursor` は `structuredContent._meta` に返る。継続時は行境界を付けず、`cursor` と任意の `maxBytes` を渡す。この tool と `resources/read` は同じ検証済み bounded reader を共有し、protocol method も互換性のため維持される。", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["uri"] = new JsonObject + { + ["type"] = "string", + ["minLength"] = 1, + ["maxLength"] = McpBoundedText.MaxResourceUriChars, + ["pattern"] = "^cdidx://(?:file|file-path)/.+$", + ["description"] = "Exact resource URI returned by resources/list or expanded from resources/templates/list." + }, + ["startLine"] = new JsonObject + { + ["type"] = "integer", + ["minimum"] = 1, + ["description"] = "Optional 1-based inclusive start line. Omit to begin at line 1." + }, + ["endLine"] = new JsonObject + { + ["type"] = "integer", + ["minimum"] = 1, + ["description"] = "Optional 1-based inclusive end line. Must be greater than or equal to startLine; omit to read through the resource." + }, + ["maxBytes"] = new JsonObject + { + ["type"] = "integer", + ["minimum"] = MinResourceReadMaxBytes, + ["maximum"] = MaxResourceReadMaxBytes, + ["default"] = DefaultResourceReadMaxBytes, + ["description"] = "Maximum UTF-8 bytes of file text in this page, not the JSON-RPC envelope." + }, + ["cursor"] = new JsonObject + { + ["type"] = "string", + ["minLength"] = 1, + ["maxLength"] = MaxResourceReadCursorCharacters, + ["description"] = "Opaque nextCursor from structuredContent._meta. Mutually exclusive with startLine and endLine; maxBytes may be changed." + }, + ["includeGenerated"] = new JsonObject + { + ["type"] = "boolean", + ["default"] = false, + ["description"] = "Allow reading generated resources; false keeps generated files excluded." + } + }, + ["required"] = new JsonArray { "uri" }, + ["allOf"] = new JsonArray + { + new JsonObject + { + ["not"] = new JsonObject + { + ["required"] = new JsonArray { "cursor", "startLine" } + } + }, + new JsonObject + { + ["not"] = new JsonObject + { + ["required"] = new JsonArray { "cursor", "endLine" } + } + } + } + }, + ReadOnlyAnnotations()), CreateToolDefinition( "find_in_file", "Use this when the target file is already known and you need literal or regex navigation inside it. Prefer `excerpt` on returned lines as the next step. Find literal substring matches inside one known indexed file or a small explicit file list, with line numbers and short surrounding context. / 対象ファイルが既に分かっていて、その中を literal または regex で移動したいときに使う。次は返された行の `excerpt` を優先する。既知のインデックス済みファイル1件または少数の明示ファイル群の中で、行番号と短い前後文脈付きの一致を探す。", diff --git a/src/CodeIndex/Mcp/McpToolFilter.cs b/src/CodeIndex/Mcp/McpToolFilter.cs index b821f7a37b..cc43bac1c2 100644 --- a/src/CodeIndex/Mcp/McpToolFilter.cs +++ b/src/CodeIndex/Mcp/McpToolFilter.cs @@ -50,6 +50,7 @@ private McpToolFilter(HashSet enabled) "files", "find_in_file", "excerpt", + "read_resource", "map", "analyze_symbol", "status", diff --git a/src/CodeIndex/Mcp/McpToolHandlers.ArgumentValidation.cs b/src/CodeIndex/Mcp/McpToolHandlers.ArgumentValidation.cs index df32649ca5..a2ad86fc9d 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.ArgumentValidation.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.ArgumentValidation.cs @@ -104,6 +104,29 @@ public partial class McpServer && maxResponseBytes <= 0) return CreateIntegerMinimumArgumentError(toolName, "maxResponseBytes", minimum: 1, actual: maxResponseBytes); + if (toolName == "read_resource") + { + if (args["maxBytes"] is JsonValue maxBytesValue + && maxBytesValue.TryGetValue(out var maxBytes) + && (maxBytes < MinResourceReadMaxBytes || maxBytes > MaxResourceReadMaxBytes)) + return CreateIntegerRangeArgumentError( + toolName, + "maxBytes", + MinResourceReadMaxBytes, + MaxResourceReadMaxBytes, + maxBytes); + + if (args["startLine"] is JsonValue startLineValue + && startLineValue.TryGetValue(out var startLine) + && startLine < 1) + return CreateIntegerMinimumArgumentError(toolName, "startLine", minimum: 1, actual: startLine); + + if (args["endLine"] is JsonValue endLineValue + && endLineValue.TryGetValue(out var endLine) + && endLine < 1) + return CreateIntegerMinimumArgumentError(toolName, "endLine", minimum: 1, actual: endLine); + } + return null; } @@ -256,7 +279,7 @@ private static bool TryGetExpectedJsonType(string toolName, string argumentName, "project" or "capability" or "scopes" or "fields" or "visibility" or "excludeVisibility" or "includeSymbolKind" or "excludeSymbolKind" or "commits" or "changedBetween" or "files" 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 "guardScope" or + "query" or "uri" or "lang" or "kind" or "format" or "rankBy" or "since" or "cursor" or "guardScope" or "solution" or "symbol" or "groupBy" or "category" or "language" or "severity" or "explain" or "snippetFocus" or "bucket" or "minConfidence" or "extension" or "alias" or "description" or "context" or "toolInvocationContext" or "db" or "followSymlinks" or "recipe" or "auditScope" => "string", diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Instructions.cs b/src/CodeIndex/Mcp/McpToolHandlers.Instructions.cs index 3850f3c425..8499762922 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Instructions.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Instructions.cs @@ -47,16 +47,22 @@ bool All(params string[] names) parts.Add("Use 'definition' for symbol lookup."); var guidedFlowTools = new List(); - foreach (var name in new[] { "search", "definition", "references", "callers", "callees", "outline", "map", "excerpt" }) + foreach (var name in new[] { "search", "definition", "references", "callers", "callees", "outline", "map", "excerpt", "read_resource" }) if (On(name)) guidedFlowTools.Add(name); if (guidedFlowTools.Count > 0) { parts.Add("Investigation flow: search broadly, use definition for declarations, references for usage sites, callers/callees for call graph impact, outline/map for structure, then excerpt or resources/read for focused line ranges. Prefer pagination, path/lang filters, exactName/exactSubstring, and prefix over dumping large files. 調査順序: まず広く search し、宣言は definition、利用箇所は references、呼び出し影響は callers/callees、構造把握は outline/map、その後に excerpt または resources/read で必要な行範囲だけを読んでください。大きなファイルを丸ごと読む前に pagination、path/lang filter、exactName/exactSubstring、prefix で絞り込んでください。"); } - parts.Add("For an exact known repository-relative path, expand the cdidx://file-path/{path} template from resources/templates/list and pass that URI to resources/read. 正確なリポジトリ相対 path が分かっている場合は、resources/templates/list の cdidx://file-path/{path} template を展開し、その URI を resources/read に渡してください。"); + if (On("read_resource")) + parts.Add("For an exact known repository-relative path, expand the cdidx://file-path/{path} template from resources/templates/list and pass that URI to the typed `read_resource` tool. The legacy resources/read method remains available. 正確なリポジトリ相対 path が分かっている場合は、resources/templates/list の cdidx://file-path/{path} template を展開し、その URI を型付き `read_resource` tool に渡してください。従来の resources/read method も引き続き利用できます。"); + else + parts.Add("For an exact known repository-relative path, expand the cdidx://file-path/{path} template from resources/templates/list and pass that URI to resources/read. 正確なリポジトリ相対 path が分かっている場合は、resources/templates/list の cdidx://file-path/{path} template を展開し、その URI を resources/read に渡してください。"); parts.Add("For browsing, call resources/list with optional path, lang, includeGenerated, and maxBytes; continue with result.nextCursor and unchanged filters. Generated files are excluded by default. browse する場合は resources/list に任意の path、lang、includeGenerated、maxBytes を渡し、同じ filter のまま result.nextCursor で継続してください。generated file は既定で除外されます。"); - parts.Add("For resources/read, use startLine/endLine and maxBytes, then continue truncated reads with result._meta.nextCursor. resources/read では startLine/endLine と maxBytes を使い、切り詰められた場合は result._meta.nextCursor で継続してください。"); + if (On("read_resource")) + parts.Add("For typed `read_resource`, use 1-based inclusive startLine/endLine and a UTF-8 maxBytes budget, then continue truncated reads with structuredContent._meta.nextCursor. Legacy resources/read exposes the same continuation as result._meta.nextCursor. 型付き `read_resource` では 1-based inclusive の startLine/endLine と UTF-8 maxBytes budget を使い、切り詰められた場合は structuredContent._meta.nextCursor で継続してください。従来の resources/read は同じ継続情報を result._meta.nextCursor に返します。"); + else + parts.Add("For resources/read, use startLine/endLine and maxBytes, then continue truncated reads with result._meta.nextCursor. resources/read では startLine/endLine と maxBytes を使い、切り詰められた場合は result._meta.nextCursor で継続してください。"); if (On("analyze_symbol")) parts.Add("Use 'analyze_symbol' to get definition, callers, callees, and references in one call instead of chaining separate tools."); diff --git a/tests/CodeIndex.Tests/McpServerProtocolTests.cs b/tests/CodeIndex.Tests/McpServerProtocolTests.cs index 10f5b94b14..586f9f4430 100644 --- a/tests/CodeIndex.Tests/McpServerProtocolTests.cs +++ b/tests/CodeIndex.Tests/McpServerProtocolTests.cs @@ -1228,9 +1228,11 @@ public void Initialize_ReturnsInstructions() Assert.Contains("optional path, lang, includeGenerated, and maxBytes", instructions); Assert.Contains("result.nextCursor", instructions); Assert.Contains("unchanged filters", instructions); + Assert.Contains("read_resource", instructions); Assert.Contains("resources/read", instructions); Assert.Contains("startLine/endLine", instructions); Assert.Contains("maxBytes", instructions); + Assert.Contains("structuredContent._meta.nextCursor", instructions); Assert.Contains("result._meta.nextCursor", instructions); Assert.Contains("whole-file reads", instructions); Assert.Contains("definition", instructions); diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index b864b88ac8..1454153d95 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -2258,6 +2258,174 @@ public void ResourcesRead_ReturnsIndexedFileContent() Assert.Equal(Encoding.UTF8.GetByteCount(content["text"]!.GetValue()), metadata["returnedBytes"]!.GetValue()); } + [Fact] + public void ReadResourceTool_IsTypedDiscoverableAndSharesBoundedReader_Issue4900() + { + const string uri = "cdidx://file/src/typed-resource.txt"; + const string expected = "first\nsecond\n🙂🙂🙂\nfourth"; + InsertIndexedFile("src/typed-resource.txt", "text", expected); + + var listed = _server.HandleMessage(new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 4900, + ["method"] = "tools/list", + ["params"] = new JsonObject + { + ["names"] = new JsonArray { "read_resource" }, + ["format"] = "full", + }, + })!; + var tool = Assert.Single(listed["result"]!["tools"]!.AsArray())!; + Assert.Equal("read_resource", tool["name"]!.GetValue()); + Assert.True(tool["annotations"]!["readOnlyHint"]!.GetValue()); + var schema = tool["inputSchema"]!; + Assert.False(schema["additionalProperties"]!.GetValue()); + Assert.Contains( + schema["required"]!.AsArray(), + required => required!.GetValue() == "uri"); + var properties = schema["properties"]!; + Assert.Equal("string", properties["uri"]!["type"]!.GetValue()); + Assert.Equal(McpBoundedText.MaxResourceUriChars, properties["uri"]!["maxLength"]!.GetValue()); + Assert.Equal(1, properties["startLine"]!["minimum"]!.GetValue()); + Assert.Contains("1-based inclusive", properties["startLine"]!["description"]!.GetValue(), StringComparison.Ordinal); + Assert.Equal(1, properties["endLine"]!["minimum"]!.GetValue()); + Assert.Equal(McpServer.MinResourceReadMaxBytes, properties["maxBytes"]!["minimum"]!.GetValue()); + Assert.Equal(McpServer.MaxResourceReadMaxBytes, properties["maxBytes"]!["maximum"]!.GetValue()); + Assert.Equal(McpServer.DefaultResourceReadMaxBytes, properties["maxBytes"]!["default"]!.GetValue()); + Assert.Contains("UTF-8", properties["maxBytes"]!["description"]!.GetValue(), StringComparison.Ordinal); + Assert.Equal(McpServer.MaxResourceReadCursorCharacters, properties["cursor"]!["maxLength"]!.GetValue()); + Assert.Equal(2, schema["allOf"]!.AsArray().Count); + + var nextId = 4901; + JsonNode Read(JsonObject arguments) + { + arguments["uri"] = uri; + return _server.HandleMessage(new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = nextId++, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "read_resource", + ["arguments"] = arguments, + }, + })!; + } + + var full = Read(new JsonObject()); + Assert.Equal(expected, full["result"]!["content"]![0]!["text"]!.GetValue()); + Assert.Equal(uri, full["result"]!["structuredContent"]!["resource"]!["uri"]!.GetValue()); + Assert.False(full["result"]!["structuredContent"]!["_meta"]!["truncated"]!.GetValue()); + + var first = Read(new JsonObject + { + ["startLine"] = 2, + ["endLine"] = 4, + ["maxBytes"] = 9, + }); + var firstResult = first["result"]!; + var firstMetadata = firstResult["structuredContent"]!["_meta"]!; + Assert.Equal("second\n", firstResult["content"]![0]!["text"]!.GetValue()); + Assert.Equal(7, firstMetadata["returnedBytes"]!.GetValue()); + Assert.True(firstMetadata["truncated"]!.GetValue()); + Assert.Equal("maxBytes", firstMetadata["truncationReason"]!.GetValue()); + + var second = Read(new JsonObject + { + ["cursor"] = firstMetadata["nextCursor"]!.GetValue(), + ["maxBytes"] = 8, + }); + var secondResult = second["result"]!; + var secondMetadata = secondResult["structuredContent"]!["_meta"]!; + Assert.Equal("🙂🙂", secondResult["content"]![0]!["text"]!.GetValue()); + Assert.Equal(8, secondMetadata["returnedBytes"]!.GetValue()); + Assert.Equal(8, secondMetadata["nextLineByteOffset"]!.GetValue()); + + var third = Read(new JsonObject + { + ["cursor"] = secondMetadata["nextCursor"]!.GetValue(), + ["maxBytes"] = 32, + }); + Assert.Equal("🙂\nfourth", third["result"]!["content"]![0]!["text"]!.GetValue()); + Assert.False(third["result"]!["structuredContent"]!["_meta"]!["truncated"]!.GetValue()); + + var invalid = Read(new JsonObject + { + ["startLine"] = 4, + ["endLine"] = 2, + }); + Assert.True(invalid["result"]!["isError"]!.GetValue()); + Assert.Equal( + McpErrorEnvelope.CategoryInvalidArgument, + invalid["result"]!["structuredContent"]!["category"]!.GetValue()); + Assert.Equal(-32602, invalid["result"]!["structuredContent"]!["jsonrpc_code"]!.GetValue()); + + var legacy = _server.HandleMessage(new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = nextId, + ["method"] = "resources/read", + ["params"] = new JsonObject + { + ["uri"] = uri, + ["startLine"] = 2, + ["endLine"] = 4, + ["maxBytes"] = 9, + }, + })!; + Assert.Equal( + firstResult["content"]![0]!["text"]!.GetValue(), + legacy["result"]!["contents"]![0]!["text"]!.GetValue()); + Assert.Equal( + firstMetadata["nextCursor"]!.GetValue(), + legacy["result"]!["_meta"]!["nextCursor"]!.GetValue()); + } + + [Fact] + public void ReadResourceTool_AdaptsPageToConfiguredResponseLimit_Issue4900() + { + const int responseLimit = 4096; + using var env = EnvironmentVariableScope.Capture("CDIDX_MCP_RESPONSE_MAX_BYTES"); + env.Set("CDIDX_MCP_RESPONSE_MAX_BYTES", responseLimit.ToString(CultureInfo.InvariantCulture)); + InsertIndexedFile("src/typed-response-budget.txt", "text", new string('<', 60_000)); + + var response = _server.HandleMessage(new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 4900, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "read_resource", + ["arguments"] = new JsonObject + { + ["uri"] = "cdidx://file/src/typed-response-budget.txt", + ["maxBytes"] = 30_000, + }, + }, + })!; + + Assert.NotNull(response["result"]); + Assert.True(_server.TrySerializeJsonNodeWithinByteLimitForTests( + response, + responseLimit, + out _, + out var responseBytes)); + Assert.InRange(responseBytes, 1, responseLimit); + var metadata = response["result"]!["structuredContent"]!["_meta"]!; + Assert.True(metadata["truncated"]!.GetValue()); + Assert.Equal("maxResponseBytes", metadata["truncationReason"]!.GetValue()); + Assert.InRange( + metadata["effectiveMaxBytes"]!.GetValue(), + McpServer.MinResourceReadMaxBytes, + 29_999); + Assert.Equal( + Encoding.UTF8.GetByteCount(response["result"]!["content"]![0]!["text"]!.GetValue()), + metadata["returnedBytes"]!.GetValue()); + } + [Fact] public void ResourcesRead_UsesExactPathWhenSubstringCandidatesSortFirst_Issue4544() { diff --git a/tests/CodeIndex.Tests/McpServerToolsListTests.cs b/tests/CodeIndex.Tests/McpServerToolsListTests.cs index f1c6508190..ec4c49c35a 100644 --- a/tests/CodeIndex.Tests/McpServerToolsListTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsListTests.cs @@ -60,7 +60,7 @@ public void ToolsList_EachToolPublishesSchemaAndExampleContract() var response = _server.HandleMessage(request)!; var tools = response["result"]!["tools"]!.AsArray(); - Assert.Equal(24, tools.Count); + Assert.Equal(McpToolFilter.KnownToolNames.Count, tools.Count); foreach (var tool in tools) { Assert.False(string.IsNullOrWhiteSpace(tool!["name"]!.GetValue())); @@ -90,7 +90,7 @@ public void ToolsList_CompactCatalogIsLightweightAndPointsToFullDefinitions_Issu var compactResult = compactResponse["result"]!; var compactTools = compactResult["tools"]!.AsArray(); - Assert.Equal(24, compactTools.Count); + Assert.Equal(McpToolFilter.KnownToolNames.Count, compactTools.Count); Assert.True( Encoding.UTF8.GetByteCount(compactResponse.ToJsonString()) < Encoding.UTF8.GetByteCount(fullResponse.ToJsonString()) / 3); @@ -127,7 +127,9 @@ public void ToolsList_NamesRetrievesSelectedFullDefinitions_Issue4724() Assert.True(tool["inputSchema"]!["additionalProperties"] is not null); var meta = response["result"]!["_meta"]!; Assert.Equal(1, meta["response_controls"]!["tools_total"]!.GetValue()); - Assert.Equal(24, meta["response_controls"]!["enabled_tools_total"]!.GetValue()); + Assert.Equal( + McpToolFilter.KnownToolNames.Count, + meta["response_controls"]!["enabled_tools_total"]!.GetValue()); Assert.True(meta["response_controls"]!["names_filtered"]!.GetValue()); Assert.Equal("name_filtered", meta["catalog_scope"]!.GetValue()); Assert.False(meta["discovery_contract"]!["tools_list_is_authoritative"]!.GetValue()); @@ -210,13 +212,13 @@ public void ToolsList_InvalidCatalogControlsReturnInvalidParams_Issue4724() } [Fact] - public void ToolsList_Returns23Tools() + public void ToolsList_ReturnsAllKnownTools() { var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; var response = _server.HandleMessage(request)!; var tools = response["result"]!["tools"]!.AsArray(); - Assert.Equal(24, tools.Count); + Assert.Equal(McpToolFilter.KnownToolNames.Count, tools.Count); var names = tools.Select(t => t!["name"]!.GetValue()).ToList(); Assert.Contains("search", names); @@ -229,6 +231,7 @@ public void ToolsList_Returns23Tools() Assert.Contains("files", names); Assert.Contains("find_in_file", names); Assert.Contains("excerpt", names); + Assert.Contains("read_resource", names); Assert.Contains("map", names); Assert.Contains("analyze_symbol", names); Assert.Contains("status", names); @@ -313,7 +316,7 @@ public void ToolsList_LimitAndCursorPageDiscoveryCatalog_Issue4304() Assert.Equal(new[] { "search", "definition", "references" }, firstTools.Select(tool => tool!["name"]!.GetValue()).ToArray()); var controls = firstResult["_meta"]!["response_controls"]!; - Assert.Equal(24, controls["tools_total"]!.GetValue()); + Assert.Equal(McpToolFilter.KnownToolNames.Count, controls["tools_total"]!.GetValue()); Assert.Equal(3, controls["tools_returned"]!.GetValue()); Assert.Equal(0, controls["tools_offset"]!.GetValue()); Assert.Equal(3, controls["tools_page_size"]!.GetValue()); From caca7ad2349afb273de4da89cf169731de6ac39f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 31 Jul 2026 09:56:09 +0900 Subject: [PATCH 2/2] Address MCP read integration review (#4900) --- changelog.d/unreleased/4900.added.md | 2 + src/CodeIndex/Mcp/McpServer.Resources.cs | 10 +++ src/CodeIndex/Mcp/McpToolDefinitions.cs | 4 +- .../Mcp/McpToolHandlers.BatchQuery.cs | 1 + tests/CodeIndex.Tests/McpServerTests.cs | 66 ++++++++++++++++++- 5 files changed, 79 insertions(+), 4 deletions(-) diff --git a/changelog.d/unreleased/4900.added.md b/changelog.d/unreleased/4900.added.md index f1760a9880..69aae2719c 100644 --- a/changelog.d/unreleased/4900.added.md +++ b/changelog.d/unreleased/4900.added.md @@ -11,7 +11,9 @@ affected: - src/CodeIndex/Mcp/McpToolCatalog.cs - src/CodeIndex/Mcp/McpToolFilter.cs - src/CodeIndex/Mcp/McpToolHandlers.ArgumentValidation.cs + - src/CodeIndex/Mcp/McpToolHandlers.BatchQuery.cs - src/CodeIndex/Mcp/McpToolHandlers.Instructions.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs - tests/CodeIndex.Tests/McpServerProtocolTests.cs - tests/CodeIndex.Tests/McpServerTests.cs - tests/CodeIndex.Tests/McpServerToolsListTests.cs diff --git a/src/CodeIndex/Mcp/McpServer.Resources.cs b/src/CodeIndex/Mcp/McpServer.Resources.cs index 7e911fd65f..4c12ac1186 100644 --- a/src/CodeIndex/Mcp/McpServer.Resources.cs +++ b/src/CodeIndex/Mcp/McpServer.Resources.cs @@ -974,6 +974,14 @@ private JsonNode HandleResourcesRead( private JsonNode ExecuteReadResource(JsonNode? id, JsonNode? args) { var resourceResponse = HandleResourcesRead(id, args, adaptForToolResult: true); + if (resourceResponse["result"] is JsonObject existingToolResult + && existingToolResult["isError"] is JsonValue existingErrorValue + && existingErrorValue.TryGetValue(out var existingIsError) + && existingIsError) + { + return resourceResponse; + } + if (resourceResponse["error"] is JsonObject error) { var errorData = error["data"] as JsonObject; @@ -1017,6 +1025,7 @@ private JsonNode ExecuteReadResource(JsonNode? id, JsonNode? args) var mimeType = TryReadStringValue(content["mimeType"]) ?? "text/plain"; var structuredContent = new JsonObject { + ["api_version"] = JsonOutputContract.ApiVersion, ["resource"] = new JsonObject { ["uri"] = content["uri"]?.DeepClone(), @@ -1118,6 +1127,7 @@ private int GetEffectiveResourceReadMaxBytes( }, ["structuredContent"] = new JsonObject { + ["api_version"] = JsonOutputContract.ApiVersion, ["resource"] = new JsonObject { ["uri"] = resourceUri, diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 0b9fbb3429..f86ee57605 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -395,7 +395,7 @@ private static JsonObject BuildToolsListCatalogMeta(JsonArray tools, int returne ["workspace_health"] = ToolNameArray(enabledToolNames, "status", "validate", "languages", "ping"), ["discovery"] = ToolNameArray(enabledToolNames, "search", "map", "files", "symbols", "outline", "deps"), ["symbol_navigation"] = ToolNameArray(enabledToolNames, "definition", "references", "callers", "callees", "analyze_symbol", "impact_analysis"), - ["file_reading"] = ToolNameArray(enabledToolNames, "excerpt", "find_in_file"), + ["file_reading"] = ToolNameArray(enabledToolNames, "excerpt", "find_in_file", "read_resource"), ["batching"] = ToolNameArray(enabledToolNames, "batch_query"), ["analysis"] = ToolNameArray(enabledToolNames, "unused_symbols", "symbol_hotspots"), ["index_maintenance"] = ToolNameArray(enabledToolNames, "index", "backfill_fold"), @@ -406,7 +406,7 @@ private static JsonObject BuildToolsListCatalogMeta(JsonArray tools, int returne WorkflowMeta(enabledToolNames, "first_pass_orientation", "Check whether the existing index can be trusted, then inspect repository shape.", "status", "map", "languages", "search"), WorkflowMeta(enabledToolNames, "go_to_implementation", "Find candidate code and retrieve the smallest useful implementation context.", "search", "definition", "excerpt"), WorkflowMeta(enabledToolNames, "trace_call_graph", "Move from a symbol to usage, callers/callees, and blast-radius analysis.", "references", "callers", "callees", "impact_analysis"), - WorkflowMeta(enabledToolNames, "safe_file_review", "Locate files and read constrained excerpts without dumping whole large files.", "files", "find_in_file", "excerpt"), + WorkflowMeta(enabledToolNames, "safe_file_review", "Locate files and read constrained excerpts or bounded resources without dumping whole large files.", "files", "find_in_file", "excerpt", "read_resource"), WorkflowMeta(enabledToolNames, "large_question_batch", "Bundle independent read-only lookups while respecting response budgets.", "batch_query"), WorkflowMeta(enabledToolNames, "index_freshness_repair", "Diagnose stale or partial indexes and refresh only when needed.", "status", "index", "backfill_fold", "validate"), WorkflowMeta(enabledToolNames, "report_capability_gap", "Report missing or poor extraction/ranking behavior in natural language.", "suggest_improvement"), diff --git a/src/CodeIndex/Mcp/McpToolHandlers.BatchQuery.cs b/src/CodeIndex/Mcp/McpToolHandlers.BatchQuery.cs index 72a31007fc..bddbeedbd0 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.BatchQuery.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.BatchQuery.cs @@ -314,6 +314,7 @@ void AppendRateLimitedSlot(int requestIndex, string? slotId, string? toolName, J "files" => ExecuteFiles(null, toolArgs), "find_in_file" => ExecuteFindInFile(null, toolArgs), "excerpt" => ExecuteExcerpt(null, toolArgs), + "read_resource" => ExecuteReadResource(null, toolArgs), "map" => ExecuteMap(null, toolArgs), "analyze_symbol" => ExecuteAnalyzeSymbol(null, toolArgs), "status" => ExecuteStatus(null, toolArgs), diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 1454153d95..502e853818 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -2316,8 +2316,48 @@ JsonNode Read(JsonObject arguments) var full = Read(new JsonObject()); Assert.Equal(expected, full["result"]!["content"]![0]!["text"]!.GetValue()); - Assert.Equal(uri, full["result"]!["structuredContent"]!["resource"]!["uri"]!.GetValue()); - Assert.False(full["result"]!["structuredContent"]!["_meta"]!["truncated"]!.GetValue()); + var fullStructured = full["result"]!["structuredContent"]!; + Assert.Equal(JsonOutputContract.ApiVersion, fullStructured["api_version"]!.GetValue()); + Assert.Equal(uri, fullStructured["resource"]!["uri"]!.GetValue()); + Assert.False(fullStructured["_meta"]!["truncated"]!.GetValue()); + + var fileReadingTools = listed["result"]!["_meta"]!["capability_groups"]!["file_reading"]!.AsArray(); + Assert.Contains( + fileReadingTools, + name => name!.GetValue() == "read_resource"); + + var batched = _server.HandleMessage(new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = nextId++, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "batch_query", + ["arguments"] = new JsonObject + { + ["queries"] = new JsonArray + { + new JsonObject + { + ["tool"] = "read_resource", + ["arguments"] = new JsonObject + { + ["uri"] = uri, + ["startLine"] = 1, + ["endLine"] = 1, + }, + }, + }, + }, + }, + })!; + var batchSlot = Assert.Single(batched["result"]!["structuredContent"]!["results"]!.AsArray())!; + Assert.True(batchSlot["ok"]!.GetValue()); + Assert.Equal("first", batchSlot["summary"]!.GetValue()); + Assert.Equal( + JsonOutputContract.ApiVersion, + batchSlot["result"]!["api_version"]!.GetValue()); var first = Read(new JsonObject { @@ -2381,6 +2421,28 @@ JsonNode Read(JsonObject arguments) Assert.Equal( firstMetadata["nextCursor"]!.GetValue(), legacy["result"]!["_meta"]!["nextCursor"]!.GetValue()); + + var missingDbPath = Path.Combine(Path.GetTempPath(), $"cdidx-missing-{Guid.NewGuid():N}", "codeindex.db"); + using var missingServer = new McpServer(missingDbPath, "0.1.1"); + var missing = missingServer.HandleMessage(new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = nextId + 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "read_resource", + ["arguments"] = new JsonObject { ["uri"] = uri }, + }, + })!; + Assert.True(missing["result"]!["isError"]!.GetValue()); + Assert.Equal( + McpErrorEnvelope.CategoryIndexMissing, + missing["result"]!["structuredContent"]!["category"]!.GetValue()); + Assert.Contains( + "Database not found", + missing["result"]!["content"]![0]!["text"]!.GetValue(), + StringComparison.Ordinal); } [Fact]