diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index ffc858c18c..fe3aa674ab 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -792,7 +792,7 @@ The documented `status --json` trust contract spans `fold_ready`, `fold_ready_re `ReferenceResult` includes `is_self_reference` and `is_mutual_recursion`; `CallerResult` includes `has_self_reference` and `has_mutual_recursion`. These fields identify self-recursive edges and direct two-symbol cycles without removing valid recursive calls from default graph results. Reader APIs that need a non-recursive view can opt into self-reference exclusion. -MCP tool calls return structured JSON in `structuredContent` plus a short summary in `content`, so clients can consume typed data directly. +MCP tool calls return structured JSON in `structuredContent` plus a short summary in `content`, so clients can consume typed data directly. Text content blocks include `mimeType`: `application/json` when a structured payload is present and `text/plain` otherwise. Tool input schemas also carry common JSON Schema constraints (`minimum` / `maximum` for limits and line counts, `maxLength` for free text, `pattern` for workspace-relative path filters, and `enum` for common kind values) so MCP-aware clients can reject invalid requests before dispatch. Exact-match flag compatibility is documented in [USER_GUIDE.md](USER_GUIDE.md#flag-compatibility-and-migrations). Keep MCP schemas aligned with that table: `search.exact` is the legacy alias for `exactSubstring`, while name-based tools use `exact` as the legacy alias for `exactName`. Do not add new exact-match aliases without updating the compatibility table, CLI help, MCP descriptions, and changelog fragment together. @@ -2602,7 +2602,11 @@ mock に頼らないリリース前検証として、`install.sh --reinstall-rea 見つかった場所を使い、その後に platform default として Windows では `%LOCALAPPDATA%\cdidx\logs\`、macOS では `~/Library/Logs/cdidx/`、 Linux では `~/.local/state/cdidx/logs/` を使う。ファイル名は `stderr-YYYYMMDD.log`。 -保持世代は新しい 30 ファイルまで。通常の開発/テストサイクルで +`CDIDX_LOG_FORMAT=json` または `--log-format json` で 1 行 1 JSON object +(`ts`、`level`、`msg`)の JSONL に切り替えられる。`CDIDX_LOG_RETAIN` / +`--log-retain-count` は保持ファイル数、`CDIDX_LOG_MAX_SIZE_MB` / +`--log-max-size-mb` は日次ファイルのサイズローテーション上限を指定する。 +保持世代の既定は新しい 30 ファイルまで。通常の開発/テストサイクルで ワークツリー直下に永続ログが増えないよう、`src/CodeIndex/bin/...` と `tests/.../bin/...` からのリポジトリ内開発実行は既定で対象外として いる。完全に無効化したい場合は `CDIDX_DISABLE_PERSISTENT_LOG=1`、 diff --git a/README.md b/README.md index 604acb0336..2af097ad48 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Output controls: | Need | Option | |---|---| -| Owner-only persistent stderr logs on POSIX | Global tool stderr logs are forced to `0600` permissions on every open, including existing date-stamped log files. | +| Owner-only persistent stderr logs on POSIX | Global tool stderr logs are forced to `0600` permissions on every open, including existing date-stamped log files. Use `--log-format text|json`, `--log-retain-count `, `--log-max-size-mb `, or the matching `CDIDX_LOG_*` environment variables to make lifecycle logs JSONL-friendly and rotate them for aggregation. | | Checked-in configuration | Use `.cdidx/config.json` for repository defaults such as `search.limit`, `search.snippet_lines`, and `search.max_line_width`; run `cdidx validate-config` to validate the discovered file. | | ASCII-only terminal output | Use `--ascii`, `CDIDX_ASCII=1`, `NO_UNICODE`, `TERM=dumb`, accessibility env hints, or a non-UTF-8 locale. Spinners use pipe, slash, dash, and backslash frames; progress bars use `#` / `-`; very narrow terminals fall back to percentage-only progress. | | Color and terminal capability | `--color auto` emits ANSI only for capable interactive terminals; `TERM=dumb`, `CI=true`, missing Unix terminal hints, `NO_COLOR`, or `CLICOLOR=0` disable ANSI/progress control sequences. `--palette basic|256|truecolor` can override the `COLORTERM` / `TERM` color-depth detection. | @@ -108,7 +108,7 @@ file completion. | Search surfaces | CLI-first output for humans and machines; full-text, symbol, reference, caller/callee, dependency, map, inspect, and excerpt commands. | | Ranking and filters | Public/exported symbol matches rank ahead of protected, internal, and private matches. Use `--no-visibility-rank` for legacy order, and `--visibility` / `--exclude-visibility` with `symbols`, `definition`, `unused`, and `hotspots`. Query defaults can be adjusted with `CDIDX_DEFAULT_LIMIT`, `CDIDX_DEFAULT_SNIPPET_LINES`, and `CDIDX_DEFAULT_MAX_LINE_WIDTH`; explicit CLI flags still win. | | Project scoping | `.sln` / `.csproj`-aware --project <name|path> filters for indexing and queries, plus `--solution ` when a workspace has multiple solution files. | -| MCP integration | MCP server support for AI clients such as Claude Code, Cursor, and Windsurf, including tools, indexed-file resources, starter prompts, and `Language support:` descriptions sourced from the same registries as `cdidx languages`. | +| MCP integration | MCP server support for AI clients such as Claude Code, Cursor, and Windsurf, including tools, indexed-file resources, starter prompts, schema constraints for local argument validation, `mimeType` on text content blocks, and `Language support:` descriptions sourced from the same registries as `cdidx languages`. | | Freshness | Parallel full-scan extraction with `--parallelism`, incremental refreshes with `--files` and `--commits`, continuous `--watch`, exact `status --check`, and configurable stale thresholds via `--stale-after` / `CDIDX_STALE_AFTER`. | | Storage | Local-first `.cdidx/codeindex.db` storage. Query commands run from nested directories prefer the outermost ancestor `.cdidx/codeindex.db` before falling back to the current directory. `--data-dir `, `CDIDX_DATA_DIR`, or `XDG_DATA_HOME` can move default SQLite storage outside the workspace; explicit `--db ` still wins. | | DB maintenance | New indexes use SQLite incremental auto-vacuum. `cdidx vacuum` reclaims free pages from existing DBs, including a one-time full `VACUUM` conversion for legacy no-autovacuum DBs, and `status --json` reports metrics under `db_pragma_settings`. | @@ -299,7 +299,7 @@ cdidx mcp | 検索面 | CLI-first の人間向け / 機械処理向け出力。全文検索、シンボル、参照、caller/callee、依存関係、map、inspect、excerpt コマンドを提供します。 | | 順位と filter | public/exported なシンボル一致を protected、internal、private より優先します。従来順は `--no-visibility-rank`、可視性の include / exclude は `symbols`、`definition`、`unused`、`hotspots` の `--visibility` / `--exclude-visibility` で指定できます。query 既定値は `CDIDX_DEFAULT_LIMIT`、`CDIDX_DEFAULT_SNIPPET_LINES`、`CDIDX_DEFAULT_MAX_LINE_WIDTH` で調整でき、明示 CLI flag が常に優先されます。 | | project scope | `.sln` / `.csproj` を使った --project <name|path> filter で index と query を .NET project 配下へ絞り込めます。workspace に solution が複数ある場合は `--solution ` を指定します。 | -| MCP 連携 | Claude Code、Cursor、Windsurf などの AI クライアント向け MCP server。tools、インデックス済みファイル resources、starter prompts、`cdidx languages` と同じ言語レジストリ由来の `Language support:` 説明を提供します。 | +| MCP 連携 | Claude Code、Cursor、Windsurf などの AI クライアント向け MCP server。tools、インデックス済みファイル resources、starter prompts、ローカル引数検証用の schema constraints、text content block の `mimeType`、`cdidx languages` と同じ言語レジストリ由来の `Language support:` 説明を提供します。 | | freshness | `--parallelism` による parallel full-scan、`--files` / `--commits` による差分更新、`--watch` による継続更新、`status --check` による完全一致確認、`--stale-after` / `CDIDX_STALE_AFTER` による age threshold 上書きに対応します。 | | storage | `.cdidx/codeindex.db` に保存する local-first 設計。ネストしたディレクトリからの query コマンドは、current directory にフォールバックする前に最上位祖先の `.cdidx/codeindex.db` を優先します。既定の SQLite 保存先は `--data-dir `、`CDIDX_DATA_DIR`、`XDG_DATA_HOME` で workspace 外へ移せます。明示的な `--db ` は引き続き最優先です。 | | DB maintenance | 新規 index DB は SQLite incremental auto-vacuum を使います。既存 DB は `cdidx vacuum` で free page を回収でき、legacy no-autovacuum DB は初回だけ full `VACUUM` で変換します。`status --json` は `db_pragma_settings` 配下に metrics を出力します。 | diff --git a/changelog.d/unreleased/1685.fixed.md b/changelog.d/unreleased/1685.fixed.md new file mode 100644 index 0000000000..84cbd5ad83 --- /dev/null +++ b/changelog.d/unreleased/1685.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 1685 +affected: + - src/CodeIndex/Mcp/McpServer.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - README.md + - DEVELOPER_GUIDE.md +--- + +## English + +- **MCP text content blocks now declare MIME type (#1685)** — tool responses include `mimeType: "application/json"` when structured JSON is attached and `text/plain` otherwise, giving clients a stable rendering hint. + +## 日本語 + +- **MCP の text content block が MIME type を宣言するようになりました (#1685)** — tool response は構造化 JSON を伴う場合に `mimeType: "application/json"`、それ以外では `text/plain` を含み、クライアントが安定して表示方法を選べます。 diff --git a/changelog.d/unreleased/1692.fixed.md b/changelog.d/unreleased/1692.fixed.md new file mode 100644 index 0000000000..cb6feb11c9 --- /dev/null +++ b/changelog.d/unreleased/1692.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 1692 +affected: + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - README.md + - DEVELOPER_GUIDE.md +--- + +## English + +- **MCP tool schemas now advertise common validation constraints (#1692)** — `tools/list` includes bounds for limits and line counts, length caps for free-text inputs, path traversal/NUL guards for path filters, and common enum values for kind filters so clients can validate earlier. + +## 日本語 + +- **MCP tool schema が共通の検証制約を公開するようになりました (#1692)** — `tools/list` は limit / 行番号の上下限、自由入力文字列の長さ上限、path filter の traversal / NUL ガード、kind filter の共通 enum 値を含むため、クライアント側で早期検証できます。 diff --git a/changelog.d/unreleased/1788.fixed.md b/changelog.d/unreleased/1788.fixed.md new file mode 100644 index 0000000000..c7aa63bc05 --- /dev/null +++ b/changelog.d/unreleased/1788.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 1788 +affected: + - src/CodeIndex/Cli/GlobalToolLog.cs + - src/CodeIndex/Cli/ProgramRunner.cs + - tests/CodeIndex.Tests/ProgramRunnerTests.cs + - README.md + - DEVELOPER_GUIDE.md +--- + +## English + +- **Global tool lifecycle logs can now be JSONL and configurable (#1788)** — `--log-format json`, `--log-retain-count`, `--log-max-size-mb`, and the matching `CDIDX_LOG_*` environment variables let operators emit structured log lines and tune retention/size rotation without string parsing. + +## 日本語 + +- **global tool の lifecycle log を JSONL 化し設定可能にしました (#1788)** — `--log-format json`、`--log-retain-count`、`--log-max-size-mb` と対応する `CDIDX_LOG_*` 環境変数により、文字列解析に頼らず構造化ログを出力し、保持数とサイズローテーションを調整できます。 diff --git a/src/CodeIndex/Cli/GlobalToolLog.cs b/src/CodeIndex/Cli/GlobalToolLog.cs index 04510ec7a0..576a859d0c 100644 --- a/src/CodeIndex/Cli/GlobalToolLog.cs +++ b/src/CodeIndex/Cli/GlobalToolLog.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Security.Cryptography; using System.Text; +using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; @@ -13,6 +14,9 @@ namespace CodeIndex.Cli; internal static class GlobalToolLog { private const int RetainedLogFileCount = 30; + internal const string LogFormatEnvironmentVariable = "CDIDX_LOG_FORMAT"; + internal const string LogRetainEnvironmentVariable = "CDIDX_LOG_RETAIN"; + internal const string LogMaxSizeMbEnvironmentVariable = "CDIDX_LOG_MAX_SIZE_MB"; private const string RedactedValue = ""; private static readonly AsyncLocal CurrentSession = new(); private static readonly Regex SensitiveAssignmentPattern = new( @@ -38,15 +42,16 @@ internal static class GlobalToolLog var logDirectory = ResolveLogDirectory(); Directory.CreateDirectory(logDirectory); HardenLogFiles(logDirectory); - var logPath = Path.Combine(logDirectory, $"stderr-{DateTime.UtcNow.ToString("yyyyMMdd", CultureInfo.InvariantCulture)}.log"); + var options = LogOptions.FromEnvironment(); + var logPath = ResolveLogPath(logDirectory, options); var writer = new StreamWriter(new FileStream(logPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite), new UTF8Encoding(false)) { AutoFlush = true, }; SetLogFilePermissions(logPath); - PruneOldLogs(logDirectory); + PruneOldLogs(logDirectory, options.RetainCount); - var session = new Session(writer, logPath); + var session = new Session(writer, logPath, options.Format); CurrentSession.Value = session; session.AttachErrorMirror(); session.Write("INFO", $"session_start pid={Environment.ProcessId} version={appVersion}"); @@ -303,14 +308,32 @@ private static string GetHomeDirectoryOrOriginal(string original) return string.IsNullOrWhiteSpace(home) ? original : home; } - private static void PruneOldLogs(string logDirectory) + private static string ResolveLogPath(string logDirectory, LogOptions options) + { + var date = DateTime.UtcNow.ToString("yyyyMMdd", System.Globalization.CultureInfo.InvariantCulture); + if (options.MaxSizeBytes <= 0) + return Path.Combine(logDirectory, $"stderr-{date}.log"); + + for (var index = 0; index < 10_000; index++) + { + var suffix = index == 0 ? "" : $"-{index}"; + var candidate = Path.Combine(logDirectory, $"stderr-{date}{suffix}.log"); + if (!File.Exists(candidate) || new FileInfo(candidate).Length < options.MaxSizeBytes) + return candidate; + } + + return Path.Combine(logDirectory, $"stderr-{date}-{Guid.NewGuid():N}.log"); + } + + private static void PruneOldLogs(string logDirectory, int retainedLogFileCount) { try { var oldLogs = new DirectoryInfo(logDirectory) .EnumerateFiles("stderr-*.log", SearchOption.TopDirectoryOnly) - .OrderByDescending(file => file.Name, StringComparer.Ordinal) - .Skip(RetainedLogFileCount) + .OrderByDescending(file => file.LastWriteTimeUtc) + .ThenByDescending(file => file.Name, StringComparer.Ordinal) + .Skip(retainedLogFileCount) .ToList(); foreach (var file in oldLogs) @@ -438,13 +461,15 @@ private sealed class Session : IDisposable { private readonly object _gate = new(); private readonly StreamWriter _writer; + private readonly string _format; private TextWriter? _originalError; private TextWriter? _teeError; private bool _disposed; - public Session(StreamWriter writer, string logPath) + public Session(StreamWriter writer, string logPath, string format) { _writer = writer; + _format = format; LogPath = logPath; } @@ -472,9 +497,21 @@ public void Write(string level, string message) try { - _writer.WriteLine(string.Create( - CultureInfo.InvariantCulture, - $"{DateTimeOffset.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ} [{level}] {message}")); + if (string.Equals(_format, "json", StringComparison.Ordinal)) + { + _writer.WriteLine(JsonSerializer.Serialize(new Dictionary + { + ["ts"] = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture), + ["level"] = level, + ["msg"] = message, + })); + } + else + { + _writer.WriteLine(string.Create( + CultureInfo.InvariantCulture, + $"{DateTimeOffset.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ} [{level}] {message}")); + } _writer.Flush(); } catch (Exception ex) when (ex is IOException or ObjectDisposedException) @@ -508,6 +545,26 @@ public void Dispose() } } + internal sealed record LogOptions(string Format, int RetainCount, long MaxSizeBytes) + { + public static LogOptions FromEnvironment() + { + var format = Environment.GetEnvironmentVariable(LogFormatEnvironmentVariable)?.Trim().ToLowerInvariant(); + if (format is not "json") + format = "text"; + + var retainCount = RetainedLogFileCount; + if (int.TryParse(Environment.GetEnvironmentVariable(LogRetainEnvironmentVariable), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsedRetain)) + retainCount = Math.Clamp(parsedRetain, 1, 10_000); + + long maxSizeBytes = 0; + if (int.TryParse(Environment.GetEnvironmentVariable(LogMaxSizeMbEnvironmentVariable), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsedMb) && parsedMb > 0) + maxSizeBytes = parsedMb * 1024L * 1024L; + + return new LogOptions(format, retainCount, maxSizeBytes); + } + } + private sealed class TeeTextWriter(TextWriter primary, TextWriter secondary) : TextWriter { public override Encoding Encoding => primary.Encoding; diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 86d519a40c..37f452cac2 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -37,6 +37,12 @@ internal static int Run( $"fix or remove `{CdidxConfigFile.FileName}`, or set `{CdidxConfigFile.DisableEnvVar}=1` to bypass it."); } + if (!TryConsumeGlobalLogFlags(ref args, out var globalLogError)) + { + CommandErrorWriter.Write(StripErrorPrefix(globalLogError), "use --log-format , --log-retain-count , or --log-max-size-mb ."); + return CommandExitCodes.InvalidArgument; + } + using var globalToolLog = GlobalToolLog.TryStart(args, appVersion); if (configResult.Loaded) GlobalToolLog.Info($"config_file_loaded path={configResult.Path}"); @@ -333,6 +339,86 @@ internal static bool TryConsumeQuietFlag(ref string[] args) return quiet; } + internal static bool TryConsumeGlobalLogFlags(ref string[] args, out string error) + { + error = string.Empty; + var kept = new List(args.Length); + var passthrough = false; + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (passthrough) + { + kept.Add(arg); + continue; + } + + if (arg == "--") + { + passthrough = true; + kept.Add(arg); + continue; + } + + if (TryConsumeValueFlag(args, ref i, arg, "--log-format", out var format)) + { + if (format is not ("text" or "json")) + { + error = "--log-format must be `text` or `json`."; + return false; + } + Environment.SetEnvironmentVariable(GlobalToolLog.LogFormatEnvironmentVariable, format); + continue; + } + + if (TryConsumeValueFlag(args, ref i, arg, "--log-retain-count", out var retainCount)) + { + if (!int.TryParse(retainCount, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) || parsed < 1) + { + error = "--log-retain-count must be a positive integer."; + return false; + } + Environment.SetEnvironmentVariable(GlobalToolLog.LogRetainEnvironmentVariable, parsed.ToString(CultureInfo.InvariantCulture)); + continue; + } + + if (TryConsumeValueFlag(args, ref i, arg, "--log-max-size-mb", out var maxSizeMb)) + { + if (!int.TryParse(maxSizeMb, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) || parsed < 1) + { + error = "--log-max-size-mb must be a positive integer."; + return false; + } + Environment.SetEnvironmentVariable(GlobalToolLog.LogMaxSizeMbEnvironmentVariable, parsed.ToString(CultureInfo.InvariantCulture)); + continue; + } + + kept.Add(arg); + } + + args = kept.ToArray(); + return true; + } + + private static bool TryConsumeValueFlag(string[] args, ref int index, string arg, string flag, out string value) + { + value = string.Empty; + if (arg.StartsWith(flag + "=", StringComparison.Ordinal)) + { + value = arg[(flag.Length + 1)..].Trim(); + return true; + } + + if (arg != flag) + return false; + + if (index + 1 >= args.Length) + return true; + + value = args[++index].Trim(); + return true; + } + private static bool IsTruthyEnvironmentVariable(string name) { var value = Environment.GetEnvironmentVariable(name); diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 7c011969e5..23e7499bfd 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -2463,8 +2463,9 @@ private static JsonObject CreateCancelledResponse(JsonNode? id) /// Create a tool result response (MCP format). /// ツール結果レスポンスを作成(MCP形式)。 /// - private static JsonObject CreateToolResult(JsonNode? id, string text, JsonNode? structuredContent = null) + private static JsonObject CreateToolResult(JsonNode? id, string text, JsonNode? structuredContent = null, string? mimeType = null) { + mimeType ??= structuredContent is null ? "text/plain" : "application/json"; var result = new JsonObject { ["content"] = new JsonArray @@ -2472,6 +2473,7 @@ private static JsonObject CreateToolResult(JsonNode? id, string text, JsonNode? new JsonObject { ["type"] = "text", + ["mimeType"] = mimeType, ["text"] = text } } diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 13af83f623..2def9a8412 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -482,6 +482,7 @@ private JsonNode HandleToolsList(JsonNode? id) }; AddProjectScopeProperties(tools); + AddCommonSchemaConstraints(tools); // Per-deployment enablement gate (#1561). Drop any tool the operator disabled via // `CDIDX_MCP_TOOLS_ALLOW` / `CDIDX_MCP_TOOLS_DENY` so AI clients never see destructive @@ -544,4 +545,86 @@ private static void AddProjectScopeProperties(JsonArray tools) }; } } + + private static void AddCommonSchemaConstraints(JsonArray tools) + { + foreach (var tool in tools.OfType()) + { + var properties = tool["inputSchema"]?["properties"] as JsonObject; + if (properties == null) + continue; + + var toolName = tool["name"]?.GetValue() ?? string.Empty; + foreach (var (name, schema) in properties) + ApplyCommonSchemaConstraint(toolName, name, schema); + } + } + + private static void ApplyCommonSchemaConstraint(string toolName, string name, JsonNode? schema) + { + if (schema is not JsonObject obj) + return; + + if (obj["oneOf"] is JsonArray oneOf) + { + foreach (var option in oneOf) + ApplyCommonSchemaConstraint(toolName, name, option); + } + + if (obj["type"]?.GetValue() == "array" && obj["items"] is JsonObject items) + ApplyCommonSchemaConstraint(toolName, name, items); + + switch (name) + { + case "query": + case "description": + case "context": + case "toolInvocationContext": + obj.TryAdd("minLength", 1); + obj.TryAdd("maxLength", 1024); + break; + case "path": + case "project": + case "solution": + obj.TryAdd("minLength", 1); + obj.TryAdd("maxLength", 4096); + obj.TryAdd("pattern", @"^(?!/)(?![A-Za-z]:)(?!.*(^|/)\.\.(/|$))(?!.*\u0000).*$"); + AppendConstraintDescription(obj, "Must be workspace-relative, non-empty, and must not contain NUL bytes or `..` path traversal segments."); + break; + case "excludePaths": + obj.TryAdd("maxItems", 100); + break; + case "limit": + obj.TryAdd("minimum", 1); + obj.TryAdd("maximum", MaxLimit); + break; + case "startLine": + case "endLine": + obj.TryAdd("minimum", 1); + break; + case "before": + case "after": + obj.TryAdd("maximum", MaxContextLines); + break; + case "kind": + if (toolName is "references") + obj.TryAdd("enum", new JsonArray { "call", "instantiate", "subscribe", "unsubscribe", "friend", "attribute", "annotation", "type_reference" }); + else if (toolName is "callers" or "callees") + obj.TryAdd("enum", new JsonArray { "call", "instantiate", "subscribe", "unsubscribe", "friend" }); + break; + case "lang": + case "language": + obj.TryAdd("pattern", "^[A-Za-z0-9_+.#-]{1,64}$"); + obj.TryAdd("maxLength", 64); + break; + } + } + + private static void AppendConstraintDescription(JsonObject obj, string sentence) + { + var description = obj["description"]?.GetValue(); + if (string.IsNullOrWhiteSpace(description) || description.Contains(sentence, StringComparison.Ordinal)) + return; + obj["description"] = $"{description} {sentence}"; + } } diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 27599343e6..fa1ddcdd6b 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -2139,6 +2139,44 @@ public void ToolsList_SearchIncludesPathFilterParams() Assert.NotNull(properties["excludeTests"]); } + [Fact] + public void ToolsList_CommonSchemasAdvertiseClientSideConstraints() + { + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""")!; + var response = _server.HandleMessage(request)!; + + var tools = response["result"]!["tools"]!.AsArray(); + var searchTool = tools.First(t => t!["name"]!.GetValue() == "search")!; + var searchProperties = searchTool["inputSchema"]!["properties"]!; + Assert.Equal(1, searchProperties["query"]!["minLength"]!.GetValue()); + Assert.Equal(1024, searchProperties["query"]!["maxLength"]!.GetValue()); + Assert.Equal(1, searchProperties["limit"]!["minimum"]!.GetValue()); + Assert.Equal(200, searchProperties["limit"]!["maximum"]!.GetValue()); + + var pathStringSchema = searchProperties["path"]!["oneOf"]!.AsArray()[0]!; + Assert.Equal(4096, pathStringSchema["maxLength"]!.GetValue()); + Assert.NotNull(pathStringSchema["pattern"]); + + var referencesTool = tools.First(t => t!["name"]!.GetValue() == "references")!; + var kindEnum = referencesTool["inputSchema"]!["properties"]!["kind"]!["enum"]!.AsArray() + .Select(v => v!.GetValue()) + .ToArray(); + Assert.Contains("call", kindEnum); + Assert.Contains("type_reference", kindEnum); + } + + [Fact] + public void ToolCall_WithStructuredContent_DeclaresJsonMimeType() + { + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ping","arguments":{}}}""")!; + + var response = _server.HandleMessage(request)!; + + var content = response["result"]!["content"]!.AsArray()[0]!; + Assert.Equal("text", content["type"]!.GetValue()); + Assert.Equal("application/json", content["mimeType"]!.GetValue()); + } + [Fact] public void ToolsList_NavigationDescriptionsIncludeConcreteExamples() { diff --git a/tests/CodeIndex.Tests/ProgramRunnerTests.cs b/tests/CodeIndex.Tests/ProgramRunnerTests.cs index 6015d36e46..54f479b9ed 100644 --- a/tests/CodeIndex.Tests/ProgramRunnerTests.cs +++ b/tests/CodeIndex.Tests/ProgramRunnerTests.cs @@ -289,6 +289,45 @@ public void Run_ForcedGlobalToolLogging_WritesLifecycleAndMirrorsStderr() } } + [Fact] + public void Run_ForcedGlobalToolLogging_JsonFormatWritesJsonLines() + { + var logDir = Path.Combine(Path.GetTempPath(), $"cdidx_global_tool_log_json_{Guid.NewGuid():N}"); + Directory.CreateDirectory(logDir); + using var env = EnvironmentVariableScope.Capture( + "CDIDX_FORCE_GLOBAL_TOOL_LOG", + "CDIDX_DISABLE_PERSISTENT_LOG", + "CDIDX_GLOBAL_TOOL_LOG_DIR", + GlobalToolLog.LogFormatEnvironmentVariable, + GlobalToolLog.LogRetainEnvironmentVariable, + GlobalToolLog.LogMaxSizeMbEnvironmentVariable); + + try + { + env.Set("CDIDX_FORCE_GLOBAL_TOOL_LOG", "1"); + env.Set("CDIDX_DISABLE_PERSISTENT_LOG", null); + env.Set("CDIDX_GLOBAL_TOOL_LOG_DIR", logDir); + + var (exitCode, _, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["--log-format", "json", "definitely-not-a-command"], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("Unknown command: definitely-not-a-command", stderr); + + var logPath = Directory.GetFiles(logDir, "stderr-*.log", SearchOption.TopDirectoryOnly).Single(); + var firstLine = File.ReadLines(logPath).First(); + using var document = JsonDocument.Parse(firstLine); + Assert.Equal("INFO", document.RootElement.GetProperty("level").GetString()); + Assert.Contains("session_start", document.RootElement.GetProperty("msg").GetString()); + Assert.True(document.RootElement.TryGetProperty("ts", out _)); + } + finally + { + TestProjectHelper.DeleteDirectory(logDir); + } + } + [Fact] public void Run_ForcedGlobalToolLogging_OnUnix_HardensExistingAndCurrentLogFiles() { @@ -419,6 +458,56 @@ public void Run_ForcedGlobalToolLogging_PrunesToThirtyDailyFiles() } } + [Fact] + public void Run_ForcedGlobalToolLogging_HonorsRetainCountAndSizeRotation() + { + var logDir = Path.Combine(Path.GetTempPath(), $"cdidx_global_tool_log_rotation_{Guid.NewGuid():N}"); + Directory.CreateDirectory(logDir); + using var env = EnvironmentVariableScope.Capture( + "CDIDX_FORCE_GLOBAL_TOOL_LOG", + "CDIDX_DISABLE_PERSISTENT_LOG", + "CDIDX_GLOBAL_TOOL_LOG_DIR", + GlobalToolLog.LogFormatEnvironmentVariable, + GlobalToolLog.LogRetainEnvironmentVariable, + GlobalToolLog.LogMaxSizeMbEnvironmentVariable); + + try + { + for (var i = 0; i < 4; i++) + { + var path = Path.Combine(logDir, $"stderr-2024010{i + 1}.log"); + File.WriteAllText(path, $"old {i}"); + File.SetLastWriteTimeUtc(path, new DateTime(2024, 1, i + 1, 0, 0, 0, DateTimeKind.Utc)); + } + + var currentPath = Path.Combine(logDir, $"stderr-{DateTime.UtcNow:yyyyMMdd}.log"); + File.WriteAllBytes(currentPath, new byte[1024 * 1024]); + File.SetLastWriteTimeUtc(currentPath, DateTime.UtcNow); + + env.Set("CDIDX_FORCE_GLOBAL_TOOL_LOG", "1"); + env.Set("CDIDX_DISABLE_PERSISTENT_LOG", null); + env.Set("CDIDX_GLOBAL_TOOL_LOG_DIR", logDir); + + var (exitCode, _, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["--log-retain-count=2", "--log-max-size-mb=1", "definitely-not-a-command"], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("Unknown command: definitely-not-a-command", stderr); + + var logs = Directory.GetFiles(logDir, "stderr-*.log", SearchOption.TopDirectoryOnly) + .Select(Path.GetFileName) + .OrderBy(name => name, StringComparer.Ordinal) + .ToArray(); + Assert.Equal(2, logs.Length); + Assert.Contains($"stderr-{DateTime.UtcNow:yyyyMMdd}-1.log", logs); + } + finally + { + TestProjectHelper.DeleteDirectory(logDir); + } + } + [Fact] public void Run_ForcedGlobalToolLogging_CanBeDisabledExplicitly() {