diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 33da20289..d9a4778ed 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -398,8 +398,17 @@ sanitized, bounded fields. Add `api_version` when introducing or auditing a public top-level CLI JSON DTO. MCP JSON-RPC uses `McpServer`'s camelCase options for the protocol envelope while tool structured content keeps its documented machine-readable keys. Every object-shaped tool `structuredContent` envelope carries -root-level `api_version`, injected by `CreateToolResult`; sanitize/redact values before mutating `JsonObject` / -`JsonNode` instances. LSP, quickfix, and SARIF outputs follow their external +root-level `api_version`, injected by the success and typed-error response builders; sanitize/redact values before mutating `JsonObject` / +`JsonNode` instances. Every full `tools/list` definition also publishes a draft +2020-12 `outputSchema` generated by `McpToolOutputSchemas`; its reusable definitions +cover versioned success envelopes, rows, readiness, pagination/truncation, warnings, +and typed tool errors. Success and typed-error variants require a per-tool `tool` +discriminator with a tool-name `const`, typed errors carry the same root `api_version`, +and open compatibility values remain bounded by finite nesting plus property, +array-item, and string-length limits. Keep field names and nesting +aligned with actual structured results, and keep the tool-name switch exhaustive so a +newly registered structured tool cannot ship without an output contract. Compact catalog entries remain +definition-incomplete and direct clients to the full catalog. LSP, quickfix, and SARIF outputs follow their external schemas rather than the CLI snake_case contract. GitHub/report helpers and worker/private storage paths use their own bounded serializers because they are either API clients, persisted local state, or process-internal protocols. The @@ -3827,8 +3836,16 @@ DOM で組み立てる `JsonObject` payload は sanitized / bounded 済み field 公開 top-level CLI JSON DTO を追加または audit するときは `api_version` を追加してください。MCP JSON-RPC は protocol envelope に `McpServer` の camelCase option を使い、tool structured content は文書化済みの machine-readable key を保ちます。object 形式のすべての tool `structuredContent` envelope は -`CreateToolResult` が追加する root-level `api_version` を持ちます。`JsonObject` / `JsonNode` を mutate する前に値を sanitize / -redact してください。LSP、quickfix、SARIF 出力は CLI snake_case contract ではなく外部 schema に +success / typed-error response builder が追加する root-level `api_version` を持ちます。`JsonObject` / `JsonNode` を mutate する前に値を sanitize / +redact してください。full `tools/list` の各 definition は `McpToolOutputSchemas` が生成する draft 2020-12 +`outputSchema` も公開し、再利用可能な definition で version 付き success envelope、row、readiness、 +pagination / truncation、warning、型付き tool error を表します。success / typed-error variant は +tool 名の `const` を持つ tool ごとの `tool` discriminator を必須とし、typed error も同じ root +`api_version` を持ちます。互換性のための open な値は有限の nesting と property 数、array item 数、 +string 長の上限で bounded に保ちます。field 名と nesting を実際の structured +result に合わせ、新しく登録した structured tool が output contract なしで出荷されないよう、tool-name switch は +網羅的に保ってください。compact catalog entry は +引き続き definition-incomplete とし、client を full catalog へ案内します。LSP、quickfix、SARIF 出力は CLI snake_case contract ではなく外部 schema に 従います。GitHub/report helper と worker/private storage path は API client、永続化ローカル状態、 process-internal protocol のいずれかなので、それぞれの bounded serializer を使います。 `LocalJsonlJsonWriterOptions` の relaxed encoder は private append-only JSONL diagnostic 専用であり、 diff --git a/README.md b/README.md index 31fc86c91..68eacaea2 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,11 @@ incomplete generation; JSON still reports `status: "partial"`. versioned surfaces are the `cdidx` CLI, CLI JSON output, and `cdidx mcp` JSON-RPC interface. There is no public library / SDK API. See [INTEGRATION_POLICY.md](INTEGRATION_POLICY.md#api-surface-and-library-use). +Full MCP `tools/list` definitions include bounded draft 2020-12 `outputSchema` +contracts with a required per-tool `tool` discriminator for structured success, +partial, and versioned typed-error results. Open compatibility values have finite +nesting plus property, array-item, and string-length bounds; compact catalogs point +clients to the full definitions instead of duplicating those schemas. ## CLI JSON Error Contract @@ -697,6 +702,10 @@ commit し、構造化 `file_errors` を返して partial-result 終了コード バージョニング契約の対象は、`cdidx` CLI、CLI JSON 出力、`cdidx mcp` の JSON-RPC interface です。公開 library / SDK API は提供していません。詳細は [INTEGRATION_POLICY.md](INTEGRATION_POLICY.md#api-surface-and-library-use) を参照してください。 +MCP の full `tools/list` definition は、tool ごとに必須の `tool` discriminator を持つ structured +success、partial、version 付き typed-error result 用の bounded な draft 2020-12 `outputSchema` +contract を含みます。open な互換値にも有限の nesting と property 数、array item 数、string 長の +上限を設け、compact catalog は schema を重複させず client を full definition へ案内します。 ## CLI JSON エラー契約 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 843586547..fc53df535 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -26,6 +26,7 @@ Use the full suite by default. Use targeted filters only while iterating locally - These test-only packages are separate from the production dependency rule in `src/CodeIndex`, which still allows only `Microsoft.Data.Sqlite` at runtime. - `FsCheck.Xunit` is reserved for property-based tests that assert universal invariants (never-throws contracts, idempotence, "output is parseable by downstream consumer") across randomly generated inputs. Use it to complement, not replace, the example-based `[Fact]` / `[Theory]` tests — pick FsCheck when the property is a universally quantified claim, and an example test when a specific concrete case is the contract. - Test parallelism: enabled by default across independent test classes. Tests that touch process-global state such as SQLite pool resets, environment variables, or current-directory overrides must use an explicit non-parallel collection. Console-sensitive classes share one non-parallel xUnit collection, so they remain serial with each other and do not run beside independent classes that may write request-id or global diagnostics to the process console. Use `ConsoleCapture` for ordinary capture and keep every direct `Console.Out` / `Console.Error` swap under `TestConsoleLock.Gate`. Snapshot and assert global console writers under the same gate so another test cannot replace a writer between capture completion and the assertion. That gate aliases the production `ConsoleStreamOwnership` gate so console synchronization and scoped production redirects cannot retain a test writer after its capture ends. +- MCP structured-output schema coverage is split between the catalog assertions in `McpServerToolsListTests` and actual-result validation in `McpServerOutputSchemaTests`. It requires every full catalog entry to advertise an `outputSchema`, validates actual success and typed-error results for all 24 tools plus search-recipe, empty-excerpt, and truncated variants, and rejects incomplete, versionless-error, excessive-depth, and every cross-tool success pair with the deliberately small local evaluator. Keep the per-tool `tool` discriminator, required fields, bounds, and runtime field names/nesting in sync. Extend that evaluator only for JSON Schema keywords emitted by `McpToolOutputSchemas`; compact catalog entries intentionally omit complete schemas. - Markdown unused-audit coverage indexes one real Markdown fixture containing common backtick and tilde fence-language markers. Keep default suppression, `documentation_surface` totals, reason tags, and `--all` recovery in that shared fixture. - FTS optimization recommendation coverage keeps the shared evaluator exact at one write below, at, and one write above the 25-write threshold. Status, explain, optimize dry-run, optimize execution, and vacuum maintenance guidance must expose the same `recommended`, `action`, `reason`, `threshold_writes`, `observed_writes`, and `state`; stale batches, known WAL-stale snapshots, forward-incompatible schema stamps, and unavailable legacy counters/page snapshots suppress the recommendation, query-only status performs no source writes, and execution uses the focused counter/page/forward-contract/freshness snapshot instead of full status scans. A hot-WAL fixture opened through an explicit `immutable=1` URI must prove that status, standalone optimize dry-run, and the `index --optimize` dry-run alias preserve the same stale recommendation. A WAL or freelist state of `unknown` cannot select the optimize command, and a successful optimize reports the reset counter afterward. - Full-scan CLI and MCP no-op coverage treats one repository-wide reusable-stat snapshot read and one folded-readiness verification as performance contracts. Keep assertions for one snapshot read, one stat lookup per candidate, one folded verification, and no content load for unchanged files when changing incremental indexing. @@ -969,6 +970,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - これらの test-only package は `src/CodeIndex` の本番依存ルールとは別であり、runtime 側は引き続き `Microsoft.Data.Sqlite` のみを許容する。 - `FsCheck.Xunit` はランダム生成入力に対する普遍的不変条件(never-throws、idempotence、"出力が downstream consumer で parse 可能" 等)を表明する property-based テスト専用です。例ベースの `[Fact]` / `[Theory]` を置き換えるのではなく補完するもので、普遍量化された主張なら FsCheck、特定の具体ケースが契約なら例ベースという形で使い分けてください。 - テスト並列実行: 独立したテストクラス間ではデフォルトで有効です。SQLite pool の解放、環境変数の変更、カレントディレクトリの上書きのような process-global 状態を触るテストは、明示的な non-parallel collection に入れてください。console-sensitive class は同じ non-parallel な xUnit collection を共有するため、互いに直列実行され、request-id や global diagnostics を process console へ書く可能性がある独立 class とも並列実行されません。通常の capture には `ConsoleCapture` を使い、`Console.Out` / `Console.Error` を直接差し替える場合は `TestConsoleLock.Gate` で保護してください。global console writer の snapshot 取得と assertion も同じ gate 内で行い、capture 完了から assertion までの間に別のテストが writer を差し替えないようにします。この gate は本番の `ConsoleStreamOwnership` gate と同一なので、console 同期処理や scoped redirect が capture 終了後も test writer を保持することを防ぎます。 +- MCP structured-output schema の coverage は `McpServerToolsListTests` の catalog assertion と `McpServerOutputSchemaTests` の actual-result validation に分けます。full catalog の全 entry が `outputSchema` を公開することを要求し、全24 tool の実際の success / typed-error result に加えて search recipe、empty excerpt、truncated variant を検証し、不完全な payload、version のない error、過剰な nesting、全 tool 間の success 組み合わせを意図的に小さく保った local evaluator で拒否します。tool ごとの `tool` discriminator、required field、bound、runtime の field 名 / nesting を同期してください。この evaluator は `McpToolOutputSchemas` が出力する JSON Schema keyword に必要な場合だけ拡張し、compact catalog entry は完全な schema を意図的に省略します。 - Markdown の unused audit coverage は、一般的な backtick / tilde fence の language marker を含む実 Markdown fixture を1回 index します。同じ fixture で既定抑制、`documentation_surface` totals、reason tag、`--all` による復元を維持してください。 - FTS optimization recommendation coverage は、25 write threshold の1つ下、ちょうど、1つ上で shared evaluator の境界を固定します。status、explain、optimize dry-run、optimize execution、vacuum maintenance guidance は同じ `recommended`、`action`、`reason`、`threshold_writes`、`observed_writes`、`state` を公開し、stale batch、既知の WAL-stale snapshot、forward-incompatible な schema stamp、利用できない legacy counter / page snapshot は recommendation を抑止します。query-only status は source に書き込まず、execution は full status scan ではなく counter / page / forward-contract / freshness に限定した snapshot を使います。hot WAL fixture を明示的な `immutable=1` URI で開き、status、standalone optimize dry-run、`index --optimize` dry-run alias が同じ stale recommendation を保持することも証明します。WAL または freelist の state が `unknown` の場合は optimize command を選択せず、成功した optimize は reset 後の counter を返す必要があります。 - full-scan CLI と MCP の no-op coverage は、リポジトリ全体の reusable-stat snapshot read と folded-readiness verification がそれぞれ 1 回であることを performance contract とします。incremental indexing を変更するときは、snapshot read が 1 回、候補ごとの stat lookup が 1 回、folded verification が 1 回、unchanged file の content load が 0 回という assertion を維持してください。 diff --git a/changelog.d/unreleased/4898.added.md b/changelog.d/unreleased/4898.added.md new file mode 100644 index 000000000..84f3646d3 --- /dev/null +++ b/changelog.d/unreleased/4898.added.md @@ -0,0 +1,26 @@ +--- +category: added +issues: + - 4898 +affected: + - src/CodeIndex/Mcp/McpToolOutputSchemas.cs + - src/CodeIndex/Mcp/McpServer.Responses.cs + - src/CodeIndex/Mcp/McpServer.ToolDispatch.cs + - src/CodeIndex/Mcp/McpToolHandlers.BatchQuery.cs + - src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs + - src/CodeIndex/Mcp/McpToolCatalog.cs + - tests/CodeIndex.Tests/McpServerToolsListTests.cs + - tests/CodeIndex.Tests/McpServerOutputSchemaTests.cs + - tests/CodeIndex.Tests/McpServerToolsCallTests.cs + - README.md + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **MCP tools now publish typed structured-output schemas (#4898)** — Every full `tools/list` entry advertises a bounded draft 2020-12 `outputSchema` covering a per-tool `tool` discriminator, versioned success and error results, rows, readiness, pagination and truncation, warnings, and finite-depth compatibility values. Catalog construction fails when a newly registered tool lacks a schema, and regression coverage validates actual success and typed-error results for all 24 tools plus search-recipe, empty, partial, incomplete, excessive-depth, and full cross-tool cases. + +## 日本語 + +- **MCP tool が型付き structured-output schema を公開するようになりました (#4898)** — full `tools/list` の全 entry が、tool ごとの `tool` discriminator、version 付き success / error result、row、readiness、pagination / truncation、warning、有限階層の互換値を表す bounded な draft 2020-12 `outputSchema` を公開します。新しく登録した tool に schema がない場合は catalog 構築を失敗させ、回帰テストで全24 tool の実際の success / typed-error result と search recipe、empty、partial、不完全、過剰階層、全 tool 間の case を検証します。 diff --git a/src/CodeIndex/Mcp/McpServer.Responses.cs b/src/CodeIndex/Mcp/McpServer.Responses.cs index 0551e92fd..9579a2da7 100644 --- a/src/CodeIndex/Mcp/McpServer.Responses.cs +++ b/src/CodeIndex/Mcp/McpServer.Responses.cs @@ -329,6 +329,8 @@ private JsonObject CreateToolResult( private void EnrichToolStructuredContent(JsonObject structuredContent) { structuredContent.TryAdd("api_version", JsonOutputContract.ApiVersion); + if (_currentToolOutputName.Value is string toolName) + structuredContent.TryAdd("tool", toolName); AddProjectFilterRootDiagnostics(structuredContent); AddConfiguredSqliteDiagnostics(structuredContent); } @@ -475,6 +477,9 @@ private JsonObject CreateToolErrorResponse(bool hasId, JsonNode? id, string mess { ClearProjectFilterRootDiagnostics(); var structuredContent = McpErrorEnvelope.BuildData(category, suggestion, retrySafe, AddCorrelationData(extraData)); + structuredContent["api_version"] = JsonOutputContract.ApiVersion; + if (_currentToolOutputName.Value is string toolName) + structuredContent["tool"] = toolName; AddConfiguredSqliteDiagnostics(structuredContent); var result = new JsonObject { @@ -510,6 +515,7 @@ private static JsonObject CreateToolDefinition(string name, string description, ["name"] = name, ["description"] = AppendLanguageSupportClause(name, description), ["inputSchema"] = inputSchema, + ["outputSchema"] = McpToolOutputSchemas.Create(name), ["examples"] = BuildToolExamples(name), }; if (annotations != null) diff --git a/src/CodeIndex/Mcp/McpServer.ToolDispatch.cs b/src/CodeIndex/Mcp/McpServer.ToolDispatch.cs index 250392d8f..6924f40e9 100644 --- a/src/CodeIndex/Mcp/McpServer.ToolDispatch.cs +++ b/src/CodeIndex/Mcp/McpServer.ToolDispatch.cs @@ -18,7 +18,7 @@ namespace CodeIndex.Mcp; public partial class McpServer : IDisposable { - + private readonly AsyncLocal _currentToolOutputName = new(); // Tool definitions are in McpToolDefinitions.cs / ツール定義は McpToolDefinitions.cs に分離 @@ -37,6 +37,10 @@ private async Task HandleToolsCallAsync(bool hasId, JsonNode? id, Json ? parsedToolName : null; var observedToolName = toolName ?? "(missing)"; + var previousToolOutputName = _currentToolOutputName.Value; + _currentToolOutputName.Value = toolName is not null && McpToolFilter.IsKnownTool(toolName) + ? toolName + : null; Database.DbDebug.ResetContext(); var metricsStartedAt = _timeProvider.GetUtcNow(); @@ -182,6 +186,7 @@ JsonObject CreateUnknownToolResponseForMetrics() } finally { + _currentToolOutputName.Value = previousToolOutputName; Database.DbDebug.ResetContext(); if (MetricsSink.IsActive) { diff --git a/src/CodeIndex/Mcp/McpToolCatalog.cs b/src/CodeIndex/Mcp/McpToolCatalog.cs index 12d6b0a75..6b4926e22 100644 --- a/src/CodeIndex/Mcp/McpToolCatalog.cs +++ b/src/CodeIndex/Mcp/McpToolCatalog.cs @@ -375,7 +375,7 @@ private static JsonArray CreateToolCatalog() ["items"] = new JsonObject { ["type"] = "string", ["minLength"] = 1, ["maxLength"] = MaxStatusProjectionFieldCharacters } } }, - ["description"] = "Return only these exact top-level structured-content fields after applying `format`, plus the standard `api_version`. Accepts one field or an array; nested paths are not supported." + ["description"] = "Return only these exact top-level structured-content fields after applying `format`, plus the standard `api_version` and `tool` discriminators. Accepts one field or an array; nested paths are not supported." } } }, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.BatchQuery.cs b/src/CodeIndex/Mcp/McpToolHandlers.BatchQuery.cs index 72a31007f..216078489 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.BatchQuery.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.BatchQuery.cs @@ -303,30 +303,40 @@ void AppendRateLimitedSlot(int requestIndex, string? slotId, string? toolName, J try { // Execute the tool and extract the structured content / ツールを実行し構造化コンテンツを抽出 - var response = toolName switch + var previousToolOutputName = _currentToolOutputName.Value; + _currentToolOutputName.Value = toolName; + JsonNode? response; + try { - "search" => ExecuteSearch(null, toolArgs), - "definition" => ExecuteDefinition(null, toolArgs), - "references" => ExecuteReferences(null, toolArgs), - "callers" => ExecuteCallers(null, toolArgs), - "callees" => ExecuteCallees(null, toolArgs), - "symbols" => ExecuteSymbols(null, toolArgs), - "files" => ExecuteFiles(null, toolArgs), - "find_in_file" => ExecuteFindInFile(null, toolArgs), - "excerpt" => ExecuteExcerpt(null, toolArgs), - "map" => ExecuteMap(null, toolArgs), - "analyze_symbol" => ExecuteAnalyzeSymbol(null, toolArgs), - "status" => ExecuteStatus(null, toolArgs), - "outline" => ExecuteOutline(null, toolArgs), - "deps" => ExecuteDeps(null, toolArgs), - "impact_analysis" => ExecuteImpactAnalysis(null, toolArgs), - "languages" => ExecuteLanguages(null, toolArgs), - "validate" => ExecuteValidate(null, toolArgs), - "unused_symbols" => ExecuteUnusedSymbols(null, toolArgs), - "symbol_hotspots" => ExecuteSymbolHotspots(null, toolArgs), - "ping" => ExecutePing(null), - _ => null, - }; + response = toolName switch + { + "search" => ExecuteSearch(null, toolArgs), + "definition" => ExecuteDefinition(null, toolArgs), + "references" => ExecuteReferences(null, toolArgs), + "callers" => ExecuteCallers(null, toolArgs), + "callees" => ExecuteCallees(null, toolArgs), + "symbols" => ExecuteSymbols(null, toolArgs), + "files" => ExecuteFiles(null, toolArgs), + "find_in_file" => ExecuteFindInFile(null, toolArgs), + "excerpt" => ExecuteExcerpt(null, toolArgs), + "map" => ExecuteMap(null, toolArgs), + "analyze_symbol" => ExecuteAnalyzeSymbol(null, toolArgs), + "status" => ExecuteStatus(null, toolArgs), + "outline" => ExecuteOutline(null, toolArgs), + "deps" => ExecuteDeps(null, toolArgs), + "impact_analysis" => ExecuteImpactAnalysis(null, toolArgs), + "languages" => ExecuteLanguages(null, toolArgs), + "validate" => ExecuteValidate(null, toolArgs), + "unused_symbols" => ExecuteUnusedSymbols(null, toolArgs), + "symbol_hotspots" => ExecuteSymbolHotspots(null, toolArgs), + "ping" => ExecutePing(null), + _ => null, + }; + } + finally + { + _currentToolOutputName.Value = previousToolOutputName; + } if (response == null) { diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs b/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs index 7be1a0bdc..085d5a90d 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs @@ -209,6 +209,8 @@ private JsonNode ExecuteStatus(JsonNode? id, JsonNode? args) } if (!projected.ContainsKey("api_version")) projected["api_version"] = structured["api_version"]!.DeepClone(); + if (!projected.ContainsKey("tool")) + projected["tool"] = structured["tool"]!.DeepClone(); structured = projected; } return CreateToolResult( diff --git a/src/CodeIndex/Mcp/McpToolOutputSchemas.cs b/src/CodeIndex/Mcp/McpToolOutputSchemas.cs new file mode 100644 index 000000000..38cda27bc --- /dev/null +++ b/src/CodeIndex/Mcp/McpToolOutputSchemas.cs @@ -0,0 +1,631 @@ +using System.Text.Json.Nodes; +using CodeIndex.Database; + +namespace CodeIndex.Mcp; + +/// +/// Builds the JSON Schema advertised for each MCP tool's structured result. +/// Keep the explicit tool-name switch exhaustive so adding a tool without an +/// output contract fails while the catalog is being constructed. +/// +internal static class McpToolOutputSchemas +{ + private const string SchemaDialect = "https://json-schema.org/draft/2020-12/schema"; + private const int MaxSchemaArrayItems = McpServer.MaxMcpPaginationOffset; + private const int MaxSchemaObjectProperties = 512; + private const int MaxSchemaStringCharacters = McpServer.MaxConfiguredResponseBytes; + private const int MaxOpenValueDepth = 8; + + public static JsonObject Create(string toolName) + { + var toolProperties = toolName switch + { + "search" => SearchProperties(), + "definition" => QueryRowsProperties(), + "references" => QueryRowsProperties(), + "callers" => QueryRowsProperties(), + "callees" => QueryRowsProperties(), + "symbols" => QueryRowsProperties(), + "files" => RowsProperties(), + "excerpt" => ExcerptProperties(), + "find_in_file" => QueryRowsProperties(), + "map" => MapProperties(), + "analyze_symbol" => AnalyzeSymbolProperties(), + "impact_analysis" => ImpactAnalysisProperties(), + "status" => StatusProperties(), + "outline" => OutlineProperties(), + "deps" => DependencyProperties(), + "languages" => LanguagesProperties(), + "validate" => ValidateProperties(), + "ping" => PingProperties(), + "batch_query" => BatchProperties(), + "index" => IndexProperties(), + "backfill_fold" => BackfillFoldProperties(), + "symbol_hotspots" => SymbolHotspotsProperties(), + "unused_symbols" => UnusedSymbolsProperties(), + "suggest_improvement" => SuggestImprovementProperties(), + _ => throw new InvalidOperationException( + $"MCP tool '{toolName}' must define a structured output schema."), + }; + toolProperties["tool"] = ConstantStringSchema(toolName); + var toolResult = new JsonObject + { + ["type"] = "object", + ["properties"] = toolProperties, + ["required"] = RequiredToolProperties(toolName), + ["not"] = new JsonObject + { + ["required"] = StringArray("category", "suggestion", "retry_safe"), + }, + ["maxProperties"] = MaxSchemaObjectProperties, + ["propertyNames"] = StringSchema(), + ["additionalProperties"] = Reference("open_value_0"), + }; + if (RequiredToolPropertyAlternatives(toolName) is JsonArray alternatives) + toolResult["anyOf"] = alternatives; + + var definitions = new JsonObject + { + ["row"] = RowSchema(), + ["rows"] = ArraySchema(Reference("row")), + ["warning"] = new JsonObject + { + ["oneOf"] = new JsonArray + { + StringSchema(), + ObjectSchema(), + }, + }, + ["warnings"] = ArraySchema(Reference("warning")), + ["readiness"] = ReadinessSchema(), + ["versioned"] = new JsonObject + { + ["type"] = "object", + ["required"] = StringArray("api_version"), + ["properties"] = new JsonObject + { + ["api_version"] = new JsonObject + { + ["type"] = "string", + ["const"] = JsonOutputContract.ApiVersion, + }, + }, + ["maxProperties"] = MaxSchemaObjectProperties, + ["propertyNames"] = StringSchema(), + }, + ["result_envelope"] = ResultEnvelopeSchema(), + ["tool_result"] = toolResult, + ["success"] = new JsonObject + { + ["allOf"] = new JsonArray + { + Reference("versioned"), + Reference("result_envelope"), + Reference("tool_result"), + }, + }, + ["error_result"] = ErrorSchema(toolName), + ["error"] = new JsonObject + { + ["allOf"] = new JsonArray + { + Reference("versioned"), + Reference("error_result"), + }, + }, + }; + for (var depth = 0; depth <= MaxOpenValueDepth; depth++) + definitions[$"open_value_{depth}"] = OpenValueSchema(depth); + + return new JsonObject + { + ["$schema"] = SchemaDialect, + ["title"] = $"{toolName} structured result", + ["type"] = "object", + ["oneOf"] = new JsonArray + { + Reference("success"), + Reference("error"), + }, + ["$defs"] = definitions, + }; + } + + private static JsonArray RequiredToolProperties(string toolName) + { + var required = toolName switch + { + "search" => StringArray(), + "definition" or "references" or "callers" or "callees" + or "symbols" or "files" or "find_in_file" => StringArray("count", "results"), + "excerpt" => StringArray("path", "totalLines"), + "map" => StringArray("fileCount"), + "analyze_symbol" => StringArray("query", "graph_sections"), + "impact_analysis" => StringArray("query", "impact_mode"), + "status" => StringArray("version", "summary"), + "outline" => StringArray("path"), + "deps" => StringArray("count", "format"), + "languages" => StringArray("languages"), + "validate" => StringArray("count", "summary"), + "ping" => StringArray("version", "timestamp", "db_path", "db_exists"), + "batch_query" => StringArray("results", "metadata", "total_count"), + "index" => StringArray("summary", "dry_run"), + "backfill_fold" => StringArray("dry_run", "progress"), + "symbol_hotspots" => StringArray("count", "grouped_by"), + "unused_symbols" => StringArray("count", "summary", "symbols"), + "suggest_improvement" => StringArray("status"), + _ => throw new InvalidOperationException( + $"MCP tool '{toolName}' must define required structured output fields."), + }; + required.Insert(0, "tool"); + return required; + } + + private static JsonArray? RequiredToolPropertyAlternatives(string toolName) + => toolName switch + { + "search" => new JsonArray + { + RequiredSchema("results"), + RequiredSchema("recipes"), + RequiredSchema("recipe", "queries"), + }, + "deps" => new JsonArray + { + RequiredSchema("edges"), + RequiredSchema("graph"), + RequiredSchema("cycles"), + }, + _ => null, + }; + + private static JsonObject RequiredSchema(params string[] propertyNames) + => new() { ["required"] = StringArray(propertyNames) }; + + private static JsonObject ResultEnvelopeSchema() + => new() + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["results"] = Reference("rows"), + ["count"] = NonNegativeIntegerSchema(), + ["total"] = Nullable(NonNegativeIntegerSchema()), + ["total_count"] = NonNegativeIntegerSchema(), + ["returned_count"] = NonNegativeIntegerSchema(), + ["truncated"] = BooleanSchema(), + ["more_available"] = BooleanSchema(), + ["next_offset"] = Nullable(NonNegativeIntegerSchema()), + ["next_cursor"] = Nullable(StringSchema()), + ["result_stable_at"] = Nullable(StringSchema()), + ["warnings"] = Reference("warnings"), + ["readiness"] = Reference("readiness"), + ["next_step_suggestion"] = GuidanceSchema(), + ["recovery_hint"] = GuidanceSchema(), + }, + ["maxProperties"] = MaxSchemaObjectProperties, + ["propertyNames"] = StringSchema(), + }; + + private static JsonObject ErrorSchema(string toolName) + => new() + { + ["type"] = "object", + ["required"] = StringArray("tool", "category", "suggestion", "retry_safe"), + ["properties"] = new JsonObject + { + ["tool"] = ConstantStringSchema(toolName), + ["category"] = StringSchema(), + ["suggestion"] = StringSchema(), + ["retry_safe"] = BooleanSchema(), + ["correlation_id"] = StringSchema(), + ["request_id"] = new JsonObject + { + ["oneOf"] = new JsonArray + { + StringSchema(), + NumberSchema(), + NullSchema(), + }, + }, + ["warnings"] = Reference("warnings"), + }, + ["maxProperties"] = MaxSchemaObjectProperties, + ["propertyNames"] = StringSchema(), + ["additionalProperties"] = Reference("open_value_0"), + }; + + private static JsonObject ReadinessSchema() + => new() + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["ready"] = BooleanSchema(), + ["is_ready"] = BooleanSchema(), + ["degraded"] = BooleanSchema(), + ["degraded_reason"] = Nullable(StringSchema()), + ["failed_checks"] = ArraySchema(StringSchema()), + }, + ["maxProperties"] = MaxSchemaObjectProperties, + ["propertyNames"] = StringSchema(), + ["additionalProperties"] = Reference("open_value_0"), + }; + + private static JsonObject SearchProperties() + { + var properties = QueryRowsProperties(); + properties["query"] = Nullable(StringSchema()); + properties["top_files"] = Reference("rows"); + properties["recipes"] = Reference("rows"); + properties["recipe"] = ObjectSchema(); + properties["query_count"] = NonNegativeIntegerSchema(); + properties["result_count"] = NonNegativeIntegerSchema(); + properties["limit_per_query"] = NonNegativeIntegerSchema(); + properties["queries"] = Reference("rows"); + return properties; + } + + private static JsonObject QueryRowsProperties() + { + var properties = RowsProperties(); + properties["query"] = Nullable(StringSchema()); + return properties; + } + + private static JsonObject RowsProperties() + => new() + { + ["results"] = Reference("rows"), + }; + + private static JsonObject ExcerptProperties() + => new() + { + ["path"] = StringSchema(), + ["content"] = Nullable(StringSchema()), + ["requestedStartLine"] = IntegerSchema(), + ["requestedEndLine"] = IntegerSchema(), + ["effectiveStartLine"] = Nullable(IntegerSchema()), + ["effectiveEndLine"] = Nullable(IntegerSchema()), + ["totalLines"] = Nullable(NonNegativeIntegerSchema()), + ["contentTruncated"] = BooleanSchema(), + }; + + private static JsonObject MapProperties() + => new() + { + ["fileCount"] = NonNegativeIntegerSchema(), + ["topFiles"] = Reference("rows"), + ["entrypoints"] = Reference("rows"), + ["languages"] = Reference("rows"), + ["modules"] = Reference("rows"), + ["indexedAt"] = Nullable(StringSchema()), + ["workspaceIndexedAt"] = Nullable(StringSchema()), + ["workspaceLatestModified"] = Nullable(StringSchema()), + ["projectRoot"] = Nullable(StringSchema()), + }; + + private static JsonObject AnalyzeSymbolProperties() + => new() + { + ["query"] = StringSchema(), + ["definitions"] = Reference("rows"), + ["nearby_symbols"] = Reference("rows"), + ["references"] = Reference("rows"), + ["callers"] = Reference("rows"), + ["callees"] = Reference("rows"), + ["graph_sections"] = ObjectSchema(), + }; + + private static JsonObject ImpactAnalysisProperties() + => new() + { + ["query"] = StringSchema(), + ["results"] = Reference("rows"), + ["impact_mode"] = StringSchema(), + ["heuristic"] = BooleanSchema(), + ["has_multiple_definitions"] = BooleanSchema(), + }; + + private static JsonObject StatusProperties() + => new() + { + ["summary"] = StringSchema(), + ["files"] = NonNegativeIntegerSchema(), + ["chunks"] = NonNegativeIntegerSchema(), + ["symbols"] = NonNegativeIntegerSchema(), + ["references"] = NonNegativeIntegerSchema(), + ["version"] = StringSchema(), + ["index_matches_workspace"] = BooleanSchema(), + ["readiness"] = Reference("readiness"), + }; + + private static JsonObject OutlineProperties() + => new() + { + ["path"] = StringSchema(), + ["symbols"] = Reference("rows"), + }; + + private static JsonObject DependencyProperties() + => new() + { + ["edges"] = Reference("rows"), + ["cycles"] = Reference("rows"), + ["graph"] = new JsonObject + { + ["type"] = "object", + ["required"] = StringArray("nodes", "edges"), + ["properties"] = new JsonObject + { + ["nodes"] = Reference("rows"), + ["edges"] = Reference("rows"), + }, + ["maxProperties"] = MaxSchemaObjectProperties, + ["propertyNames"] = StringSchema(), + ["additionalProperties"] = Reference("open_value_0"), + }, + ["format"] = StringSchema(), + }; + + private static JsonObject LanguagesProperties() + => new() + { + ["languages"] = Reference("rows"), + }; + + private static JsonObject PingProperties() + => new() + { + ["version"] = StringSchema(), + ["timestamp"] = StringSchema(), + ["db_path"] = StringSchema(), + ["db_exists"] = BooleanSchema(), + }; + + private static JsonObject BatchProperties() + => new() + { + ["results"] = Reference("rows"), + ["metadata"] = new JsonObject + { + ["type"] = "object", + ["required"] = StringArray("submitted", "executed", "errors", "estimated_response_bytes"), + ["properties"] = new JsonObject + { + ["submitted"] = NonNegativeIntegerSchema(), + ["executed"] = NonNegativeIntegerSchema(), + ["errors"] = NonNegativeIntegerSchema(), + ["total_elapsed_ms"] = NonNegativeIntegerSchema(), + ["success_count"] = NonNegativeIntegerSchema(), + ["failure_count"] = NonNegativeIntegerSchema(), + ["response_byte_limit"] = NonNegativeIntegerSchema(), + ["estimated_response_bytes"] = NonNegativeIntegerSchema(), + }, + ["maxProperties"] = MaxSchemaObjectProperties, + ["propertyNames"] = StringSchema(), + ["additionalProperties"] = Reference("open_value_0"), + }, + ["success_count"] = NonNegativeIntegerSchema(), + ["failure_count"] = NonNegativeIntegerSchema(), + ["partial_failure"] = BooleanSchema(), + ["failure_scope"] = StringSchema(), + }; + + private static JsonObject IndexProperties() + => new() + { + ["mode"] = StringSchema(), + ["summary"] = ObjectSchema(), + ["dry_run"] = BooleanSchema(), + ["readiness"] = Reference("readiness"), + }; + + private static JsonObject BackfillFoldProperties() + => new() + { + ["symbols"] = NonNegativeIntegerSchema(), + ["symbol_references"] = NonNegativeIntegerSchema(), + ["rewrite_all"] = BooleanSchema(), + ["dry_run"] = BooleanSchema(), + ["was_already_complete"] = BooleanSchema(), + ["fold_ready_before"] = BooleanSchema(), + ["fold_ready_after"] = BooleanSchema(), + ["verified"] = BooleanSchema(), + ["progress"] = ObjectSchema(), + ["fold_ready"] = BooleanSchema(), + }; + + private static JsonObject ValidateProperties() + => new() + { + ["count"] = NonNegativeIntegerSchema(), + ["summary"] = ObjectSchema(), + ["issues"] = Reference("rows"), + ["top_files"] = Reference("rows"), + ["issues_table_available"] = BooleanSchema(), + ["file_issues_data_current"] = BooleanSchema(), + }; + + private static JsonObject SymbolHotspotsProperties() + => new() + { + ["count"] = NonNegativeIntegerSchema(), + ["grouped_by"] = StringSchema(), + ["hotspots"] = Reference("rows"), + ["files"] = NonNegativeIntegerSchema(), + ["query_context"] = ObjectSchema(), + }; + + private static JsonObject UnusedSymbolsProperties() + => new() + { + ["count"] = NonNegativeIntegerSchema(), + ["graph_supported"] = Nullable(BooleanSchema()), + ["graph_support_reason"] = Nullable(StringSchema()), + ["summary"] = ObjectSchema(), + ["symbols"] = Reference("rows"), + ["symbols_by_bucket"] = ObjectSchema(), + ["returned_bucket_counts"] = ObjectSchema(), + ["returned_contract_domain_counts"] = ObjectSchema(), + ["bucket_taxonomy"] = ObjectSchema(), + }; + + private static JsonObject SuggestImprovementProperties() + => new() + { + ["status"] = StringSchema(), + ["id"] = StringSchema(), + ["revision_hash"] = StringSchema(), + ["hash"] = StringSchema(), + ["category"] = StringSchema(), + ["language"] = Nullable(StringSchema()), + ["stored_locally"] = BooleanSchema(), + ["submitted_to_github"] = BooleanSchema(), + ["github_submission_reason"] = StringSchema(), + ["lifecycle_status"] = StringSchema(), + ["cdidx_dir"] = StringSchema(), + ["duplicate_of"] = Nullable(StringSchema()), + ["duplicate_score"] = NumberSchema(), + ["upstream_url"] = StringSchema(), + ["github_issue_url"] = StringSchema(), + }; + + private static JsonObject ObjectSchema() + => new() + { + ["type"] = "object", + ["maxProperties"] = MaxSchemaObjectProperties, + ["propertyNames"] = StringSchema(), + ["additionalProperties"] = Reference("open_value_0"), + }; + + private static JsonObject RowSchema() + => new() + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["api_version"] = StringSchema(), + ["path"] = StringSchema(), + ["lang"] = StringSchema(), + ["name"] = StringSchema(), + ["kind"] = StringSchema(), + ["query"] = StringSchema(), + ["line"] = IntegerSchema(), + ["column"] = IntegerSchema(), + ["startLine"] = IntegerSchema(), + ["endLine"] = IntegerSchema(), + ["count"] = NonNegativeIntegerSchema(), + ["score"] = NumberSchema(), + ["snippet"] = StringSchema(), + ["content"] = StringSchema(), + ["result"] = ObjectSchema(), + ["uri"] = StringSchema(), + ["testFile"] = BooleanSchema(), + ["generated"] = BooleanSchema(), + }, + ["maxProperties"] = MaxSchemaObjectProperties, + ["propertyNames"] = StringSchema(), + ["additionalProperties"] = Reference("open_value_0"), + }; + + private static JsonObject StringSchema() + => new() + { + ["type"] = "string", + ["maxLength"] = MaxSchemaStringCharacters, + }; + + private static JsonObject ConstantStringSchema(string value) + => new() + { + ["type"] = "string", + ["const"] = value, + ["maxLength"] = MaxSchemaStringCharacters, + }; + + private static JsonObject OpenValueSchema(int depth) + { + var alternatives = new JsonArray + { + NullSchema(), + StringSchema(), + BooleanSchema(), + NumberSchema(), + }; + if (depth < MaxOpenValueDepth) + { + alternatives.Add(ArraySchema(Reference($"open_value_{depth + 1}"))); + alternatives.Add(new JsonObject + { + ["type"] = "object", + ["maxProperties"] = MaxSchemaObjectProperties, + ["propertyNames"] = StringSchema(), + ["additionalProperties"] = Reference($"open_value_{depth + 1}"), + }); + } + + return new JsonObject { ["oneOf"] = alternatives }; + } + + private static JsonObject BooleanSchema() + => new() { ["type"] = "boolean" }; + + private static JsonObject IntegerSchema() + => new() { ["type"] = "integer" }; + + private static JsonObject NumberSchema() + => new() { ["type"] = "number" }; + + private static JsonObject NonNegativeIntegerSchema() + => new() + { + ["type"] = "integer", + ["minimum"] = 0, + }; + + private static JsonObject NullSchema() + => new() { ["type"] = "null" }; + + private static JsonObject ArraySchema(JsonObject itemSchema) + => new() + { + ["type"] = "array", + ["items"] = itemSchema, + ["maxItems"] = MaxSchemaArrayItems, + }; + + private static JsonObject Nullable(JsonObject schema) + => new() + { + ["oneOf"] = new JsonArray + { + schema, + NullSchema(), + }, + }; + + private static JsonObject GuidanceSchema() + => new() + { + ["oneOf"] = new JsonArray + { + StringSchema(), + ObjectSchema(), + NullSchema(), + }, + }; + + private static JsonObject Reference(string definition) + => new() { ["$ref"] = $"#/$defs/{definition}" }; + + private static JsonArray StringArray(params string[] values) + { + var array = new JsonArray(); + foreach (var value in values) + array.Add(value); + return array; + } +} diff --git a/tests/CodeIndex.Tests/McpServerOutputSchemaTests.cs b/tests/CodeIndex.Tests/McpServerOutputSchemaTests.cs new file mode 100644 index 000000000..b76313f19 --- /dev/null +++ b/tests/CodeIndex.Tests/McpServerOutputSchemaTests.cs @@ -0,0 +1,418 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Mcp; + +namespace CodeIndex.Tests; + +public partial class McpServerTests +{ + [Fact] + public void ToolsList_OutputSchemasValidateActualSuccessEmptyPartialAndTypedError_Issue4898() + { + InsertIndexedFile( + "src/output-schema-a.cs", + "csharp", + "public class OutputSchemaA { public void Issue4898OutputSchemaMarker() { } }"); + InsertIndexedFile( + "src/output-schema-b.cs", + "csharp", + "public class OutputSchemaB { public void Issue4898OutputSchemaMarker() { } }"); + InsertIndexedFile( + "src/app.cs", + "csharp", + "public class App { public void Run() { } }"); + + var listResponse = _server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!)!; + var toolDefinitions = listResponse["result"]!["tools"]!.AsArray(); + var schemas = toolDefinitions + .ToDictionary( + tool => tool!["name"]!.GetValue(), + tool => tool!["outputSchema"]!.AsObject(), + StringComparer.Ordinal); + + var success = CallToolForStructuredContent("ping", new JsonObject()); + var empty = CallToolForStructuredContent( + "definition", + new JsonObject { ["query"] = "Issue4898DefinitelyMissingSymbol" }); + var partial = CallToolForStructuredContent( + "search", + new JsonObject + { + ["query"] = "Issue4898OutputSchemaMarker", + ["limit"] = 1, + }); + var typedError = CallToolForStructuredContent("search", new JsonObject()); + + Assert.Empty(empty["results"]!.AsArray()); + Assert.True(partial["truncated"]!.GetValue()); + Assert.Equal(McpErrorEnvelope.CategoryInvalidArgument, typedError["category"]!.GetValue()); + + Assert.True(MatchesSchema(success, schemas["ping"], schemas["ping"]), success.ToJsonString()); + Assert.True(MatchesSchema(empty, schemas["definition"], schemas["definition"]), empty.ToJsonString()); + Assert.True(MatchesSchema(partial, schemas["search"], schemas["search"]), partial.ToJsonString()); + Assert.True(MatchesSchema(typedError, schemas["search"], schemas["search"]), typedError.ToJsonString()); + + foreach (var (toolName, schema) in schemas) + { + var actualError = CallToolForStructuredContent( + toolName, + new JsonObject { ["__issue4898_unknown_argument"] = true }); + Assert.Equal(JsonOutputContract.ApiVersion, actualError["api_version"]!.GetValue()); + Assert.True(MatchesSchema(actualError, schema, schema), $"{toolName}: {actualError.ToJsonString()}"); + Assert.False( + MatchesSchema( + new JsonObject { ["api_version"] = JsonOutputContract.ApiVersion }, + schema, + schema), + $"{toolName} accepted an incomplete success payload."); + } + + var actualSuccesses = new Dictionary(StringComparer.Ordinal); + foreach (var tool in toolDefinitions) + { + var toolName = tool!["name"]!.GetValue(); + if (toolName == "index") + continue; // The shared seeded server is intentionally not authorized to mutate its fixture root. + var arguments = toolName switch + { + "backfill_fold" => new JsonObject { ["dry_run"] = true, ["force"] = false }, + "suggest_improvement" => new JsonObject + { + ["category"] = "output_format", + ["description"] = "The response contract should remain easy for typed clients to consume.", + ["evidencePaths"] = new JsonArray { "src/app.cs" }, + }, + _ => tool["examples"]![0]!["request"]!["params"]!["arguments"]!.DeepClone().AsObject(), + }; + var actualResult = CallToolForResult(toolName, arguments); + Assert.True( + actualResult["isError"]?.GetValue() != true, + $"{toolName} returned an error: {actualResult.ToJsonString()}"); + var actualSuccess = actualResult["structuredContent"]!.AsObject(); + Assert.True( + MatchesSchema( + actualSuccess, + schemas[toolName]["$defs"]!["result_envelope"]!.AsObject(), + schemas[toolName]), + $"{toolName} result envelope: {actualSuccess.ToJsonString()}"); + Assert.True( + MatchesSchema( + actualSuccess, + schemas[toolName]["$defs"]!["tool_result"]!.AsObject(), + schemas[toolName]), + $"{toolName} tool result: {actualSuccess.ToJsonString()}"); + Assert.True( + MatchesSchema(actualSuccess, schemas[toolName], schemas[toolName]), + $"{toolName}: {actualSuccess.ToJsonString()}"); + actualSuccesses.Add(toolName, actualSuccess.DeepClone().AsObject()); + } + + var indexRoot = Path.Combine( + Environment.CurrentDirectory, + "tests", + "CodeIndex.Tests", + "bin", + $"cdidx-output-schema-{Guid.NewGuid():N}"); + Directory.CreateDirectory(indexRoot); + try + { + File.WriteAllText(Path.Combine(indexRoot, "app.cs"), "public class IndexedApp { }"); + var indexDbPath = Path.Combine(indexRoot, ".cdidx", "codeindex.db"); + Directory.CreateDirectory(Path.GetDirectoryName(indexDbPath)!); + using var indexServer = new McpServer( + indexDbPath, + ConsoleUi.LoadVersion(), + dbPathExplicit: true); + var indexResponse = CallIndex( + indexServer, + indexRoot, + arguments => arguments["dryRun"] = true); + Assert.True( + indexResponse["result"]?["isError"]?.GetValue() != true, + indexResponse.ToJsonString()); + var indexSuccess = indexResponse["result"]!["structuredContent"]!.AsObject(); + Assert.True( + MatchesSchema(indexSuccess, schemas["index"], schemas["index"]), + indexSuccess.ToJsonString()); + actualSuccesses.Add("index", indexSuccess.DeepClone().AsObject()); + } + finally + { + Directory.Delete(indexRoot, recursive: true); + } + + var recipeResult = CallToolForResult( + "search", + new JsonObject { ["recipe"] = "risky-code", ["limit"] = 1 }); + Assert.True( + recipeResult["isError"]?.GetValue() != true, + recipeResult.ToJsonString()); + var recipeSuccess = recipeResult["structuredContent"]!.AsObject(); + Assert.True( + MatchesSchema(recipeSuccess, schemas["search"], schemas["search"]), + recipeSuccess.ToJsonString()); + + var emptyExcerptResult = CallToolForResult( + "excerpt", + new JsonObject + { + ["path"] = "src/issue4898-missing.cs", + ["startLine"] = 1, + ["endLine"] = 1, + }); + Assert.True( + emptyExcerptResult["isError"]?.GetValue() != true, + emptyExcerptResult.ToJsonString()); + var emptyExcerpt = emptyExcerptResult["structuredContent"]!.AsObject(); + Assert.Null(emptyExcerpt["effectiveStartLine"]); + Assert.Null(emptyExcerpt["effectiveEndLine"]); + Assert.Null(emptyExcerpt["totalLines"]); + Assert.True( + MatchesSchema(emptyExcerpt, schemas["excerpt"], schemas["excerpt"]), + emptyExcerpt.ToJsonString()); + + foreach (var (schemaToolName, schema) in schemas) + { + foreach (var (resultToolName, actualSuccess) in actualSuccesses) + { + Assert.Equal( + schemaToolName == resultToolName, + MatchesSchema(actualSuccess, schema, schema)); + } + } + + Assert.False( + MatchesSchema( + new JsonObject + { + ["api_version"] = JsonOutputContract.ApiVersion, + ["tool"] = "definition", + ["count"] = 0, + }, + schemas["definition"], + schemas["definition"]), + "The definition schema accepted a result without its results array."); + + var excessiveDepth = new JsonObject(); + var depthCursor = excessiveDepth; + for (var depth = 0; depth < 10; depth++) + { + var next = new JsonObject(); + depthCursor["next"] = next; + depthCursor = next; + } + var excessiveDepthResult = actualSuccesses["definition"].DeepClone().AsObject(); + excessiveDepthResult["future_contract"] = excessiveDepth; + Assert.False( + MatchesSchema( + excessiveDepthResult, + schemas["definition"], + schemas["definition"]), + "An unknown compatibility field exceeded the advertised finite nesting depth."); + + var versionlessError = typedError.DeepClone().AsObject(); + versionlessError.Remove("api_version"); + Assert.False( + MatchesSchema(versionlessError, schemas["search"], schemas["search"]), + versionlessError.ToJsonString()); + Assert.False( + MatchesSchema(success, schemas["search"], schemas["search"]), + "The search schema accepted a ping result."); + + var analyzeProperties = schemas["analyze_symbol"]["$defs"]!["tool_result"]!["properties"]!.AsObject(); + Assert.NotNull(analyzeProperties["nearby_symbols"]); + Assert.NotNull(analyzeProperties["graph_sections"]); + Assert.Null(analyzeProperties["nearbySymbols"]); + Assert.Null(analyzeProperties["graphSections"]); + var batchProperties = schemas["batch_query"]["$defs"]!["tool_result"]!["properties"]!.AsObject(); + Assert.Null(batchProperties["estimated_response_bytes"]); + Assert.NotNull(batchProperties["metadata"]!["properties"]!["estimated_response_bytes"]); + + var sharedDefinitions = schemas["search"]["$defs"]!.AsObject(); + Assert.Equal(10_000, sharedDefinitions["rows"]!["maxItems"]!.GetValue()); + Assert.Equal(512, sharedDefinitions["row"]!["maxProperties"]!.GetValue()); + Assert.Equal( + McpServer.MaxConfiguredResponseBytes, + sharedDefinitions["row"]!["properties"]!["path"]!["maxLength"]!.GetValue()); + Assert.NotNull(sharedDefinitions["open_value_0"]); + Assert.NotNull(sharedDefinitions["open_value_8"]); + } + + private JsonObject CallToolForStructuredContent(string toolName, JsonObject arguments) + => CallToolForResult(toolName, arguments)["structuredContent"]!.AsObject(); + + private JsonObject CallToolForResult(string toolName, JsonObject arguments) + { + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = toolName, + ["arguments"] = arguments, + }, + }; + + var response = _server.HandleMessage(request)!; + return response["result"]!.AsObject(); + } + + private static bool MatchesSchema(JsonNode? instance, JsonObject schema, JsonObject root) + { + if (schema["$ref"] is JsonValue reference) + { + var prefix = "#/$defs/"; + var referenceText = reference.GetValue(); + if (!referenceText.StartsWith(prefix, StringComparison.Ordinal)) + return false; + var definitionName = referenceText[prefix.Length..]; + return root["$defs"]?[definitionName] is JsonObject definition + && MatchesSchema(instance, definition, root); + } + + if (schema["oneOf"] is JsonArray alternatives + && alternatives.Count(alternative => MatchesSchema(instance, alternative!.AsObject(), root)) != 1) + { + return false; + } + + if (schema["anyOf"] is JsonArray choices + && !choices.Any(choice => MatchesSchema(instance, choice!.AsObject(), root))) + { + return false; + } + + if (schema["allOf"] is JsonArray requirements + && requirements.Any(requirement => !MatchesSchema(instance, requirement!.AsObject(), root))) + { + return false; + } + + if (schema["not"] is JsonObject exclusion && MatchesSchema(instance, exclusion, root)) + return false; + + if (schema["type"] is JsonValue type + && !MatchesSchemaType(instance, type.GetValue())) + { + return false; + } + + if (schema["const"] is JsonNode constant && !JsonNode.DeepEquals(instance, constant)) + return false; + + if (schema["required"] is JsonArray required) + { + if (instance is not JsonObject requiredObject + || required.Any(property => !requiredObject.ContainsKey(property!.GetValue()))) + { + return false; + } + } + + if (schema["properties"] is JsonObject properties && instance is JsonObject instanceObject) + { + foreach (var property in properties) + { + if (instanceObject.TryGetPropertyValue(property.Key, out var value) + && !MatchesSchema(value, property.Value!.AsObject(), root)) + { + return false; + } + } + } + + if (schema["additionalProperties"] is JsonObject additionalPropertySchema + && instance is JsonObject openObject) + { + var declaredProperties = schema["properties"] as JsonObject; + foreach (var property in openObject) + { + if (declaredProperties?.ContainsKey(property.Key) != true + && !MatchesSchema(property.Value, additionalPropertySchema, root)) + { + return false; + } + } + } + + if (schema["items"] is JsonObject items && instance is JsonArray array + && array.Any(item => !MatchesSchema(item, items, root))) + { + return false; + } + + if (schema["maxItems"] is JsonValue maxItems + && (instance is not JsonArray boundedArray + || boundedArray.Count > maxItems.GetValue())) + { + return false; + } + + if (schema["maxLength"] is JsonValue maxLength + && (instance is not JsonValue boundedString + || boundedString.GetValueKind() != JsonValueKind.String + || boundedString.GetValue().Length > maxLength.GetValue())) + { + return false; + } + + if (schema["maxProperties"] is JsonValue maxProperties + && (instance is not JsonObject boundedObject + || boundedObject.Count > maxProperties.GetValue())) + { + return false; + } + + if (schema["propertyNames"] is JsonObject propertyNameSchema + && instance is JsonObject namedObject + && namedObject.Any(property => + !MatchesSchema(JsonValue.Create(property.Key), propertyNameSchema, root))) + { + return false; + } + + if (schema["minimum"] is JsonValue minimum + && (!TryGetSchemaNumber(instance, out var actual) + || !TryGetSchemaNumber(minimum, out var lowerBound) + || actual < lowerBound)) + { + return false; + } + + return true; + } + + private static bool MatchesSchemaType(JsonNode? instance, string type) + => type switch + { + "null" => instance is null, + "object" => instance is JsonObject, + "array" => instance is JsonArray, + "string" => instance is JsonValue stringValue + && stringValue.GetValueKind() == JsonValueKind.String, + "boolean" => instance is JsonValue booleanValue + && booleanValue.GetValueKind() is JsonValueKind.True or JsonValueKind.False, + "number" => TryGetSchemaNumber(instance, out _), + "integer" => TryGetSchemaNumber(instance, out var number) + && decimal.Truncate(number) == number, + _ => false, + }; + + private static bool TryGetSchemaNumber(JsonNode? node, out decimal number) + { + number = default; + return node is JsonValue value + && value.GetValueKind() == JsonValueKind.Number + && decimal.TryParse( + value.ToJsonString(), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out number); + } +} diff --git a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs index 868799ddc..a1756bce0 100644 --- a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs @@ -5141,7 +5141,7 @@ public void ToolsCall_Status_FieldsProjectsExactCompactFields_Issue4724() var response = _server.HandleMessage(request)!; var structured = response["result"]!["structuredContent"]!.AsObject(); - Assert.Equal(new[] { "summary", "readiness", "api_version" }, structured.Select(property => property.Key).ToArray()); + Assert.Equal(new[] { "summary", "readiness", "api_version", "tool" }, structured.Select(property => property.Key).ToArray()); Assert.Contains("1 files, 2 symbols, 0 refs", structured["summary"]!.GetValue()); Assert.True(structured["readiness"]!["issues_table_available"]!.GetValue()); Assert.True(Encoding.UTF8.GetByteCount(structured.ToJsonString()) < 1_000); @@ -5150,14 +5150,14 @@ public void ToolsCall_Status_FieldsProjectsExactCompactFields_Issue4724() var fullResponse = _server.HandleMessage(fullRequest)!; var fullStructured = fullResponse["result"]!["structuredContent"]!.AsObject(); - Assert.Equal(new[] { "files", "sql_graph_contract_ready", "api_version" }, fullStructured.Select(property => property.Key).ToArray()); + Assert.Equal(new[] { "files", "sql_graph_contract_ready", "api_version", "tool" }, fullStructured.Select(property => property.Key).ToArray()); Assert.Equal(1, fullStructured["files"]!.GetValue()); Assert.True(fullStructured["sql_graph_contract_ready"]!.GetValue()); var apiVersionRequest = JsonNode.Parse("""{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"status","arguments":{"format":"compact","fields":"api_version"}}}""")!; var apiVersionResponse = _server.HandleMessage(apiVersionRequest)!; var apiVersionStructured = apiVersionResponse["result"]!["structuredContent"]!.AsObject(); - Assert.Equal(new[] { "api_version" }, apiVersionStructured.Select(property => property.Key).ToArray()); + Assert.Equal(new[] { "api_version", "tool" }, apiVersionStructured.Select(property => property.Key).ToArray()); Assert.Equal(JsonOutputContract.ApiVersion, apiVersionStructured["api_version"]!.GetValue()); using var immutableServer = new McpServer( @@ -5167,14 +5167,14 @@ public void ToolsCall_Status_FieldsProjectsExactCompactFields_Issue4724() var immutableRequest = JsonNode.Parse("""{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"status","arguments":{"format":"compact","fields":"summary"}}}""")!; var immutableResponse = immutableServer.HandleMessage(immutableRequest)!; var immutableStructured = immutableResponse["result"]!["structuredContent"]!.AsObject(); - Assert.Equal(new[] { "summary", "api_version" }, immutableStructured.Select(property => property.Key).ToArray()); + Assert.Equal(new[] { "summary", "api_version", "tool" }, immutableStructured.Select(property => property.Key).ToArray()); Assert.Null(immutableStructured["wal_stale_snapshot_risk"]); var diagnosticsRequest = JsonNode.Parse("""{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"status","arguments":{"format":"compact","fields":["wal_stale_snapshot_risk","wal_stale_snapshot_reason"]}}}""")!; var diagnosticsResponse = immutableServer.HandleMessage(diagnosticsRequest)!; var diagnosticsStructured = diagnosticsResponse["result"]!["structuredContent"]!.AsObject(); Assert.Equal( - new[] { "wal_stale_snapshot_risk", "wal_stale_snapshot_reason", "api_version" }, + new[] { "wal_stale_snapshot_risk", "wal_stale_snapshot_reason", "api_version", "tool" }, diagnosticsStructured.Select(property => property.Key).ToArray()); Assert.True(diagnosticsStructured["wal_stale_snapshot_risk"]!.GetValue()); Assert.Equal("explicit_immutable_read_only", diagnosticsStructured["wal_stale_snapshot_reason"]!.GetValue()); diff --git a/tests/CodeIndex.Tests/McpServerToolsListTests.cs b/tests/CodeIndex.Tests/McpServerToolsListTests.cs index f1c650819..1f98f63f3 100644 --- a/tests/CodeIndex.Tests/McpServerToolsListTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsListTests.cs @@ -66,6 +66,13 @@ public void ToolsList_EachToolPublishesSchemaAndExampleContract() Assert.False(string.IsNullOrWhiteSpace(tool!["name"]!.GetValue())); Assert.False(string.IsNullOrWhiteSpace(tool["description"]!.GetValue())); Assert.Equal("object", tool["inputSchema"]!["type"]!.GetValue()); + Assert.Equal( + "https://json-schema.org/draft/2020-12/schema", + tool["outputSchema"]!["$schema"]!.GetValue()); + Assert.Equal("object", tool["outputSchema"]!["type"]!.GetValue()); + Assert.Equal(2, tool["outputSchema"]!["oneOf"]!.AsArray().Count); + Assert.NotNull(tool["outputSchema"]!["$defs"]!["success"]); + Assert.NotNull(tool["outputSchema"]!["$defs"]!["error"]); var examples = tool["examples"]!.AsArray(); Assert.NotEmpty(examples); @@ -100,6 +107,7 @@ public void ToolsList_CompactCatalogIsLightweightAndPointsToFullDefinitions_Issu Assert.False(string.IsNullOrWhiteSpace(tool["description"]!.GetValue())); Assert.Equal("object", tool["inputSchema"]!["type"]!.GetValue()); Assert.Single(tool["inputSchema"]!.AsObject()); + Assert.Null(tool["outputSchema"]); Assert.Null(tool["examples"]); Assert.NotNull(tool["annotations"]); Assert.NotNull(tool["x-stability"]);