Журнал операций: история вызовов инструментов в JSONL

Каждый вызов пишется строкой JSON из фильтра в Program.cs: инструмент,
аргументы, длительность, ok, полный текст ошибки либо начало ответа.
Снимок модели в журнал не идёт — только размер картинки.

Включается только явным путём: KOMPAS_MCP_OPLOG_PATH на старте или
set_operation_log(enabled, path?) в сессии; KOMPAS_MCP_OPLOG=0 глушит,
не стирая путь. Сбой записи операцию не роняет, причина видна в
kompas_status.

tools/dev/oplog-report.ps1 разбирает накопленное: частота и медианное
время по инструментам, доля отказов, топ ошибок, ни разу не вызванные
инструменты — материал для решений, что из каталога убрать и что
переделать.

Заодно поправлены устаревшие счётчики в README: 60 инструментов
(было указано 57 при 59 фактических), 567 тестов вместо 427.
This commit is contained in:
2026-07-31 21:36:28 +03:00
parent 6b4563a053
commit 69a075c890
13 changed files with 1011 additions and 12 deletions
+25
View File
@@ -64,6 +64,31 @@ description: Внутренняя методика доработки самог
(`Core/Validation`). Переключатель — `set_auto_validate` и `KOMPAS_MCP_AUTOVALIDATE`; проверка
никогда не превращает удачную операцию в ошибку (сбой самой проверки уходит в примечание).
## Журнал операций: чем подтверждать решения о каталоге
Решения «этот инструмент убрать, тот переделать» опираются на историю вызовов, а не на память о
прошлых прогонах. Сервер пишет каждый вызов строкой JSON: `ts`, `tool`, `args`, `ms`, `ok`,
`error` (текст целиком) либо `result` (первые 500 символов) + `resultChars`, а также `session` и
`client` — по ним история режется на прогоны.
- **Устройство.** `Core/Diagnostics/OperationLog` (+`OperationLogEntry`, `OperationLogSettings`),
запись — из фильтра вызовов в `Program.cs`: единственное место, через которое проходит каждый
инструмент, там же формируется и текст ошибки. Перевод MCP-контекста в запись — `Host/ToolCallLog`
(Core о протоколе не знает: его тесты гоняются без MCP-хоста). Снимок модели в журнал не
попадает — вместо base64 пишется размер картинки.
- **Включение.** Только явным путём: `KOMPAS_MCP_OPLOG_PATH` на старте (в `.mcp.json`, секция `env`)
или `set_operation_log(enabled, path?)` в сессии; `KOMPAS_MCP_OPLOG=0` глушит, не стирая путь.
Состояние показывает `kompas_status`. Сбой записи не имеет права уронить операцию — он оседает в
`LastError` и виден в статусе.
- **Разбор.** `pwsh -NoProfile -File tools/dev/oplog-report.ps1 [-Path …]` — частота и медианное
время по инструментам, доля отказов, топ текстов ошибок и список ни разу не вызванных инструментов
(каталог для сверки берётся из атрибутов `McpServerTool` в исходниках, а не из README).
- **Как читать.** Инструмент с высокой долей отказов — кандидат на доработку (смотри тексты ошибок:
чаще это непонятная агенту схема, а не COM); инструмент, которого нет в журнале после нескольких
разных задач, — кандидат на слияние с соседом или удаление; долгие вызовы — кандидаты на пакетный
параметр. Прежде чем удалять, проверь, что задача такого рода вообще попадала в прогоны: пустота
бывает от того, что сценарий не запускали, а не от ненужности инструмента.
## Продуктизация приёма
> Если нужного инструмента ещё нет — не хардкодь обход под кейс. Заведи кейс в `usecases/`,
+10 -1
View File
@@ -39,7 +39,7 @@ validated live. Also packaged as the Claude Code plugin `kompas` (no release pub
The full tool catalog (by group) lives in [`README.md`](README.md) §«Инструменты» — the single
source of truth; do not duplicate it here.
**The catalog is shaped for the agent, not for the SDK** (57 tools, down from 84): a tool names the
**The catalog is shaped for the agent, not for the SDK** (60 tools, down from 84): a tool names the
*operation*, the variant is a parameter (`extrude(mode=boss|cut)`, `hole(type=…)`, `pattern(kind=…)`);
an object is picked by index **or** point through one tool (no `*_index` twins); list parameters
(`entities[]`, `edgeIndices[]`) run in a single STA hop; and every mutating op appends its own build-check
@@ -105,6 +105,15 @@ asking for a session restart when a rebuilt server has to be picked up.
a skill edit needs no rebuild but is only picked up by a *new* session.
- **Nothing internal leaks into a published skill** — no local SDK paths, no `usecases/`, no RAG-base
references. Internal findings belong in `kompas-mcp-dev`; only reproducible methodology ships.
- **The operation log is the fourth channel — evidence instead of recollection.** Every tool call can be
appended to a JSONL file (`Core/Diagnostics/OperationLog`, written from the `CallToolFilter` in
`Program.cs`): tool, arguments, duration, ok/error text, the first 500 chars of the answer. It writes
**only** when a path is given (`KOMPAS_MCP_OPLOG_PATH` at startup or `set_operation_log(enabled, path)`
in session) — no path, no file. `tools/dev/oplog-report.ps1` turns the accumulated history into what
decides the catalog's shape: call frequency, failure rate per tool, the most common error texts, and
the tools nobody has ever called (candidates for removal). A tool that only ever fails or never gets
called is a finding about the catalog, not about the task that met it.
### The rebuild cycle
A running server holds its own binary: with `.mcp.json` pointing straight at
+30 -6
View File
@@ -2,7 +2,7 @@
**MCP-сервер для управления CAD-системой КОМПАС-3D (АСКОН) языковой моделью.**
Сервер — COM-клиент КОМПАС-3D: подключается к установленному КОМПАС и даёт LLM 57 инструментов для
Сервер — COM-клиент КОМПАС-3D: подключается к установленному КОМПАС и даёт LLM 60 инструментов для
создания документов, построения 3D-деталей (эскиз → формообразующая операция), работы со сборками и
чертежами, импорта/экспорта STEP, прямого редактирования импортированной B-rep, а также структурного
осмотра модели (`describe_model` — дерево операций, тела, топология, переменные, МЦХ одним вызовом)
@@ -61,11 +61,11 @@ kompas-mcp.exe --version # версия бинаря, КОМПАС для эт
}
```
## Инструменты (58)
## Инструменты (60)
| Группа | Инструменты |
|---|---|
| System | `kompas_connect`, `kompas_status`, `kompas_set_visible`, `set_auto_validate` |
| System | `kompas_connect`, `kompas_status`, `kompas_set_visible`, `set_auto_validate`, `set_operation_log` |
| Documents | `document_create`, `document_open`, `document_save`, `document_close`, `document_active`, `set_part_info` |
| Sketch | `sketch_create`, `sketch_add`, `sketch_close` |
| Features | `extrude`, `revolve`, `primitive`, `fillet_edge`, `chamfer_edge`, `shell`, `rib`, `sweep`, `loft`, `pattern`, `mirror`, `hole`, `draft`, `feature_delete`, `rebuild` |
@@ -115,6 +115,30 @@ result=new|union|subtract|intersect)` строит тело по размера
> `validate_part` после каждого шага не нужен. Отключается на сессию через `set_auto_validate(false)`
> или при старте сервера переменной окружения `KOMPAS_MCP_AUTOVALIDATE=0`.
### Журнал операций
Сервер умеет записывать каждый вызов инструмента в файл JSON Lines — по строке на вызов: время,
имя инструмента, аргументы, длительность, успех или полный текст ошибки, начало ответа (снимки
модели не пишутся, только их размер). Журнал — материал для решений о самом каталоге: что зовут,
что стабильно отказывает, что не зовут никогда.
```powershell
$env:KOMPAS_MCP_OPLOG_PATH = "D:\logs\kompas-operations.jsonl" # путь = включение
$env:KOMPAS_MCP_OPLOG = "0" # временно заглушить, путь не стирая
```
Без пути журнал выключен: сервер не пишет ничего и никуда. В сессии режим переключается
инструментом `set_operation_log(enabled, path?)`, состояние видно в `kompas_status`. Сбой записи
никогда не роняет операцию — причина оседает в статусе. Сводка по накопленному:
```powershell
pwsh -NoProfile -File tools/dev/oplog-report.ps1 -Path D:\logs\kompas-operations.jsonl
```
Отчёт даёт частоту вызовов и медианное время по инструментам, долю отказов, самые частые тексты
ошибок и список инструментов, ни разу не встретившихся в журнале (каталог берётся из атрибутов
`McpServerTool` в исходниках).
> ⚠️ Вызовы зависимых инструментов выполняйте **последовательно**, дождавшись ответа: сессия
> построения хранит эскизы по `id`, а сервер обрабатывает запросы конкурентно.
@@ -124,7 +148,7 @@ result=new|union|subtract|intersect)` строит тело по размера
src/Kompas.Mcp.Core/ COM-слой: STA-диспетчер, подключение, документы, эскизы/операции,
сборки, чертежи, конвертация, снимок, инспекция модели
src/Kompas.Mcp.Host/ MCP-сервер (stdio) + определения инструментов (Tools/ — по группе на файл)
tests/Kompas.Mcp.Tests/ 427 тестов: unit (корень) + Integration/ (нужен запущенный КОМПАС)
tests/Kompas.Mcp.Tests/ 567 тестов: unit (корень) + Integration/ (нужен запущенный КОМПАС)
libs/kompas-interop/ interop-сборки КОМПАС (из SDK Samples/Common) — только для компиляции
plugin/ плагин Claude Code `kompas`: манифест, лаунчер, лок версии сервера,
навыки kompas-3d и kompas-fdm-design (источник истины)
@@ -148,8 +172,8 @@ dotnet test --filter "Category=Integration" # только integration
pwsh -NoProfile -File tools/tests/run-ps-tests.ps1 # Pester-тесты скриптов плагина
```
427 тестов: 291 unit + 136 integration. CI (Gitea Actions, `.gitea/workflows/ci.yml`) собирает проект и
гоняет `Category=Unit&Requires!=Windows` на Linux-раннере (245 тестов) — 5 тестов STA-диспетчера
567 тестов: 406 unit + 161 integration. CI (Gitea Actions, `.gitea/workflows/ci.yml`) собирает проект и
гоняет `Category=Unit&Requires!=Windows` на Linux-раннере (401 тест) — 5 тестов STA-диспетчера
помечены `Requires=Windows` (`Thread.SetApartmentState` не работает на Linux). Integration-тесты в CI
не запускаются никогда: нужен запущенный КОМПАС с GUI и лицензией. Плюс 41 Pester-тест
PowerShell-скриптов плагина и раскладки навыков — в счёт .NET-тестов не входят.
@@ -0,0 +1,145 @@
using Kompas.Mcp.Core.Documents;
namespace Kompas.Mcp.Core.Diagnostics;
/// <summary>
/// Журнал операций: дописывает в файл по строке JSON на каждый вызов инструмента. Нужен, чтобы
/// накапливать историю работы агента с сервером — по ней потом видно, какие инструменты реально
/// зовут, какие падают и какие можно убрать или переделать.
/// <para>Включается путём: <see cref="OperationLogSettings.PathVariable"/> на старте или
/// <c>set_operation_log</c> в сессии. Без пути журнал молчит.</para>
/// <para>Сбой записи никогда не ломает вызов инструмента: причина запоминается в
/// <see cref="LastError"/> (её показывают <c>kompas_status</c> и <c>set_operation_log</c>),
/// а операция продолжается как ни в чём не бывало — журнал диагностический, а не рабочий.</para>
/// </summary>
public sealed class OperationLog
{
private readonly object _gate = new();
private volatile bool _enabled;
private string? _path;
private int _written;
private string? _lastError;
public OperationLog()
: this(OperationLogSettings.ReadFromEnvironment())
{
}
public OperationLog(OperationLogOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (!options.Enabled || options.Path is null) return;
// Каталог создаём на старте: иначе первая же запись обнаружит проблему уже в фильтре,
// где её никто не увидит.
try
{
_path = OutputPath.Prepare(options.Path, OperationLogSettings.PathVariable);
_enabled = true;
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
_lastError = ex.Message;
}
}
/// <summary>Пишется ли журнал сейчас.</summary>
public bool Enabled => _enabled;
/// <summary>Файл журнала (абсолютный путь) или <c>null</c>, если путь ни разу не задавали.</summary>
public string? Path
{
get { lock (_gate) return _path; }
}
/// <summary>Сколько записей ушло в файл с момента старта сервера.</summary>
public int WrittenCount
{
get { lock (_gate) return _written; }
}
/// <summary>Причина последнего сбоя (записи или включения) — или <c>null</c>.</summary>
public string? LastError
{
get { lock (_gate) return _lastError; }
}
/// <summary>
/// Включить журнал. Путь берётся из параметра, иначе — из уже настроенного, иначе — из
/// окружения. Каталог назначения создаётся.
/// </summary>
/// <exception cref="ArgumentException">Путь не задан ни одним из способов или некорректен.</exception>
/// <returns>Файл, в который пойдёт запись.</returns>
public string Enable(string? path = null)
{
lock (_gate)
{
var target = OperationLogSettings.ResolveFile(path)
?? _path
?? OperationLogSettings.ResolveFile(
Environment.GetEnvironmentVariable(OperationLogSettings.PathVariable));
if (target is null)
{
throw new ArgumentException(
"Путь к файлу журнала не задан: укажите параметр path или переменную окружения " +
OperationLogSettings.PathVariable + ".", nameof(path));
}
_path = OutputPath.Prepare(target, nameof(path));
_lastError = null;
_enabled = true;
return _path;
}
}
/// <summary>Выключить журнал; путь запоминается, повторное включение обойдётся без него.</summary>
public void Disable()
{
lock (_gate) _enabled = false;
}
/// <summary>
/// Дописать запись о вызове. Быстрый выход, когда журнал выключен, — фильтр зовёт этот метод
/// на каждом вызове инструмента.
/// </summary>
public void Write(OperationLogEntry entry)
{
if (!_enabled || entry is null) return;
lock (_gate)
{
if (!_enabled || _path is null) return;
try
{
File.AppendAllText(_path, entry.ToJson() + Environment.NewLine);
_written++;
_lastError = null;
}
catch (Exception ex)
{
// Диагностика не имеет права уронить операцию — ни сбоем файла, ни неожиданным
// значением в аргументах.
_lastError = ex.Message;
}
}
}
/// <summary>Строка о состоянии журнала для ответов инструментов.</summary>
public string Describe()
{
lock (_gate)
{
var problem = _lastError is null ? "" : $" Последняя ошибка записи: {_lastError}.";
if (!_enabled)
{
return _path is null
? "Журнал операций выключен."
: $"Журнал операций выключен (записано за сессию: {_written}, файл {_path})." + problem;
}
return $"Журнал операций пишется в {_path} (записей за сессию: {_written})." + problem;
}
}
}
@@ -0,0 +1,135 @@
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
namespace Kompas.Mcp.Core.Diagnostics;
/// <summary>
/// Одна строка журнала операций — вызов инструмента: что звали, с чем, сколько заняло и чем
/// кончилось. Формат — JSON Lines (объект на строку), чтобы историю за месяцы можно было читать
/// потоком и грепать без парсера.
/// <para>Ответ пишется усечённым (<see cref="ResultLimit"/>): цель журнала — понять, какие
/// инструменты используются и на чём спотыкаются, а не хранить полный вывод <c>describe_model</c>.
/// Текст ошибки, наоборот, сохраняется целиком — ради него журнал и заводится.</para>
/// </summary>
public sealed record OperationLogEntry
{
/// <summary>Сколько символов ответа сохраняется.</summary>
public const int ResultLimit = 500;
/// <summary>Предел сериализованных аргументов: длинная надпись или пакет из сотен примитивов
/// не должны раздувать строку журнала до мегабайта.</summary>
public const int ArgumentsLimit = 8000;
/// <summary>Момент завершения вызова (UTC).</summary>
public required DateTimeOffset Timestamp { get; init; }
/// <summary>Имя инструмента.</summary>
public required string Tool { get; init; }
/// <summary>Аргументы вызова как их прислал клиент.</summary>
public IReadOnlyDictionary<string, JsonElement>? Arguments { get; init; }
/// <summary>Идентификатор MCP-сессии — по нему вызовы группируются в один прогон.</summary>
public string? Session { get; init; }
/// <summary>Клиент MCP (имя и версия), если он представился.</summary>
public string? Client { get; init; }
/// <summary>Длительность вызова.</summary>
public TimeSpan Duration { get; init; }
/// <summary>Успешен ли вызов (ошибка инструмента — тоже неуспех, не только исключение).</summary>
public required bool Ok { get; init; }
/// <summary>Текст ответа (будет усечён при записи).</summary>
public string? Result { get; init; }
/// <summary>Текст ошибки — тот же, что ушёл агенту.</summary>
public string? Error { get; init; }
private static readonly JsonWriterOptions WriterOptions = new()
{
// Без этого кириллица уезжает в \u04xx и журнал нечитаем глазами.
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
Indented = false,
};
/// <summary>Сериализовать запись в одну строку JSON (без перевода строки на конце).</summary>
public string ToJson()
{
using var buffer = new MemoryStream(512);
using (var writer = new Utf8JsonWriter(buffer, WriterOptions))
{
writer.WriteStartObject();
writer.WriteString("ts", Timestamp.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ"));
writer.WriteString("tool", Tool);
writer.WriteNumber("ms", (long)Math.Round(Duration.TotalMilliseconds));
writer.WriteBoolean("ok", Ok);
if (!string.IsNullOrEmpty(Session)) writer.WriteString("session", Session);
if (!string.IsNullOrEmpty(Client)) writer.WriteString("client", Client);
WriteArguments(writer);
if (Error is { Length: > 0 })
{
writer.WriteString("error", Error);
}
else if (Result is not null)
{
writer.WriteString("result", Truncate(Result, ResultLimit));
writer.WriteNumber("resultChars", Result.Length);
}
writer.WriteEndObject();
}
return Encoding.UTF8.GetString(buffer.ToArray());
}
private void WriteArguments(Utf8JsonWriter writer)
{
if (Arguments is null || Arguments.Count == 0) return;
var json = SerializeArguments(Arguments);
writer.WritePropertyName("args");
if (json.Length <= ArgumentsLimit)
{
writer.WriteRawValue(json, skipInputValidation: true);
}
else
{
// Усечённый JSON — уже не JSON, поэтому кладём его строкой: анализ увидит и сам факт
// усечения, и начало аргументов.
writer.WriteStringValue(Truncate(json, ArgumentsLimit));
}
}
private static string SerializeArguments(IReadOnlyDictionary<string, JsonElement> arguments)
{
using var buffer = new MemoryStream(256);
using (var writer = new Utf8JsonWriter(buffer, WriterOptions))
{
writer.WriteStartObject();
foreach (var (name, value) in arguments)
{
writer.WritePropertyName(name);
// WriteTo переносит значение как есть — без рефлексии и без потери типа.
value.WriteTo(writer);
}
writer.WriteEndObject();
}
return Encoding.UTF8.GetString(buffer.ToArray());
}
/// <summary>Обрезать текст до предела, пометив, сколько символов отброшено.</summary>
public static string Truncate(string text, int limit)
{
ArgumentNullException.ThrowIfNull(text);
if (limit <= 0 || text.Length <= limit) return text;
return text[..limit] + $"…[+{text.Length - limit} симв.]";
}
}
@@ -0,0 +1,71 @@
namespace Kompas.Mcp.Core.Diagnostics;
/// <summary>Режим журнала операций на старте: включён ли и в какой файл писать.</summary>
/// <param name="Enabled">Писать ли вызовы инструментов.</param>
/// <param name="Path">Файл журнала (уже с именем файла, каталог мог быть не создан).</param>
public sealed record OperationLogOptions(bool Enabled, string? Path);
/// <summary>
/// Разбор настроек журнала операций из окружения — отдельно от файловой работы, чтобы покрыть
/// тестами. Журнал никогда не пишется «куда-нибудь сам»: без явного пути
/// (<see cref="PathVariable"/> или параметр <c>set_operation_log</c>) он выключен.
/// </summary>
public static class OperationLogSettings
{
/// <summary>Переменная окружения с путём к файлу (или каталогу) журнала.</summary>
public const string PathVariable = "KOMPAS_MCP_OPLOG_PATH";
/// <summary>Переменная окружения-выключателя: позволяет заглушить журнал, не стирая путь.</summary>
public const string EnabledVariable = "KOMPAS_MCP_OPLOG";
/// <summary>Имя файла, если в настройке указан каталог.</summary>
public const string DefaultFileName = "kompas-operations.jsonl";
/// <summary>
/// Собрать режим из пары значений. Пустой путь — журнал выключен (это умолчание).
/// Выключатель понимает 0 | false | off | no | disabled (регистр не важен), всё прочее —
/// «включено», как и отсутствие переменной.
/// </summary>
public static OperationLogOptions Parse(string? enabled, string? path)
{
var file = ResolveFile(path);
return file is null
? new OperationLogOptions(false, null)
: new OperationLogOptions(ParseEnabled(enabled), file);
}
/// <summary>Прочитать режим из переменных окружения процесса.</summary>
public static OperationLogOptions ReadFromEnvironment()
=> Parse(Environment.GetEnvironmentVariable(EnabledVariable),
Environment.GetEnvironmentVariable(PathVariable));
/// <summary>Значение выключателя: пусто — включено, явные «нет» — выключено.</summary>
public static bool ParseEnabled(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return true;
return value.Trim().ToLowerInvariant() switch
{
"0" or "false" or "off" or "no" or "disabled" => false,
_ => true,
};
}
/// <summary>
/// Привести настройку к пути файла. Путь без расширения или с завершающим разделителем
/// считается каталогом — в него кладётся <see cref="DefaultFileName"/>. Проверка чисто
/// строковая (без обращения к диску): каталога может ещё не быть, его создаст запись.
/// Возвращает <c>null</c>, если путь не задан.
/// </summary>
public static string? ResolveFile(string? path)
{
if (string.IsNullOrWhiteSpace(path)) return null;
var trimmed = path.Trim();
var looksLikeDirectory =
trimmed.EndsWith(Path.DirectorySeparatorChar) ||
trimmed.EndsWith(Path.AltDirectorySeparatorChar) ||
Path.GetExtension(trimmed).Length == 0;
return looksLikeDirectory ? Path.Combine(trimmed, DefaultFileName) : trimmed;
}
}
+26 -2
View File
@@ -1,10 +1,13 @@
using System.Diagnostics;
using Kompas.Mcp.Core;
using Kompas.Mcp.Core.Assemblies;
using Kompas.Mcp.Core.Conversion;
using Kompas.Mcp.Core.Diagnostics;
using Kompas.Mcp.Core.Documents;
using Kompas.Mcp.Core.Drawings;
using Kompas.Mcp.Core.Editing;
using Kompas.Mcp.Core.Interop;
using Kompas.Mcp.Host;
using Kompas.Mcp.Core.Modeling;
using Kompas.Mcp.Core.Query;
using Kompas.Mcp.Core.Startup;
@@ -55,6 +58,11 @@ builder.Services.AddSingleton<ValidationService>();
// Режим авто-валидации живёт на весь процесс: умолчание из KOMPAS_MCP_AUTOVALIDATE, переключение — set_auto_validate.
builder.Services.AddSingleton<AutoValidation>();
// Журнал операций создаётся до хоста: его же захватывает фильтр вызовов ниже, а инструмент
// set_operation_log получает тот же экземпляр через DI.
var operationLog = new OperationLog();
builder.Services.AddSingleton(operationLog);
// MCP-сервер поверх stdio; инструменты находятся по атрибутам в этой сборке.
builder.Services
.AddMcpServer()
@@ -62,14 +70,23 @@ builder.Services
// Без фильтра клиент получает на любую ошибку безликое «An error occurred invoking '<tool>'»:
// SDK показывает текст исключения, только если оно McpException. А вся польза наших сообщений
// именно в тексте («какого параметра не хватает», «почему операция не построилась»).
// Здесь же снимается журнал операций: единственная точка, через которую проходит каждый вызов.
.WithRequestFilters(filters => filters.AddCallToolFilter(next => async (context, ct) =>
{
try { return await next(context, ct); }
var started = Stopwatch.GetTimestamp();
try
{
var result = await next(context, ct);
operationLog.Write(ToolCallLog.Describe(context, Stopwatch.GetElapsedTime(started), result, error: null));
return result;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
var message = ToolErrorText.Describe(context.Params?.Name, ex);
operationLog.Write(ToolCallLog.Describe(context, Stopwatch.GetElapsedTime(started), result: null, message));
return new CallToolResult
{
Content = [new TextContentBlock { Text = ToolErrorText.Describe(context.Params?.Name, ex) }],
Content = [new TextContentBlock { Text = message }],
IsError = true,
};
}
@@ -91,4 +108,11 @@ catch (KompasInteropException ex)
interopLog.LogWarning("{Message}", ex.Message);
}
// Состояние журнала операций — в stderr на старте: иначе включённый через окружение журнал
// работает молча, а невключившийся (плохой путь) молчит вдвойне.
if (operationLog.Enabled || operationLog.LastError is not null)
{
interopLog.LogInformation("{Message}", operationLog.Describe());
}
await app.RunAsync();
+65
View File
@@ -0,0 +1,65 @@
using Kompas.Mcp.Core.Diagnostics;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace Kompas.Mcp.Host;
/// <summary>
/// Перевод вызова инструмента из терминов MCP в запись журнала. Живёт в хосте, потому что
/// <c>Kompas.Mcp.Core</c> о протоколе не знает (и не должен: его тесты гоняются без MCP-хоста).
/// </summary>
internal static class ToolCallLog
{
/// <summary>Собрать запись о завершившемся вызове.</summary>
/// <param name="context">Контекст запроса: имя инструмента, аргументы, сессия, клиент.</param>
/// <param name="duration">Сколько занял вызов.</param>
/// <param name="result">Ответ инструмента (<c>null</c>, если вылетело исключение).</param>
/// <param name="error">Текст ошибки, ушедший агенту, или <c>null</c> при успехе.</param>
public static OperationLogEntry Describe(
RequestContext<CallToolRequestParams> context,
TimeSpan duration,
CallToolResult? result,
string? error)
{
var failed = error is not null || result?.IsError == true;
var text = ResultText(result);
return new OperationLogEntry
{
Timestamp = DateTimeOffset.UtcNow,
Tool = context.Params?.Name ?? "?",
Arguments = context.Params?.Arguments as IReadOnlyDictionary<string, System.Text.Json.JsonElement>
?? context.Params?.Arguments?.ToDictionary(a => a.Key, a => a.Value),
Session = context.Server?.SessionId,
Client = ClientName(context.Server?.ClientInfo),
Duration = duration,
Ok = !failed,
// Ошибка инструмента приходит обычным ответом с IsError — её текст ценнее, чем поле result.
Error = error ?? (failed ? text : null),
Result = failed ? null : text,
};
}
private static string? ClientName(Implementation? client)
=> client is null
? null
: string.IsNullOrEmpty(client.Version) ? client.Name : $"{client.Name} {client.Version}";
/// <summary>
/// Текст ответа. Снимок модели (base64 в <c>ImageContentBlock</c>) в журнал не попадает
/// никогда — вместо картинки пишется её размер.
/// </summary>
private static string? ResultText(CallToolResult? result)
{
if (result?.Content is not { Count: > 0 } content) return null;
var parts = content.Select(block => block switch
{
TextContentBlock text => text.Text,
ImageContentBlock image => $"<изображение {image.MimeType}, {image.Data.Length} байт>",
_ => $"<{block.Type}>",
});
return string.Join("\n", parts);
}
}
+27 -3
View File
@@ -1,14 +1,15 @@
using System.ComponentModel;
using Kompas.Mcp.Core;
using Kompas.Mcp.Core.Diagnostics;
using Kompas.Mcp.Core.Interop;
using Kompas.Mcp.Core.Validation;
using ModelContextProtocol.Server;
namespace Kompas.Mcp.Host.Tools;
/// <summary>Системные инструменты: подключение, статус, видимость окна.</summary>
/// <summary>Системные инструменты: подключение, статус, видимость окна, журнал операций.</summary>
[McpServerToolType]
public sealed class SystemTools(KompasSession session, AutoValidation autoValidation)
public sealed class SystemTools(KompasSession session, AutoValidation autoValidation, OperationLog operationLog)
{
[McpServerTool(Name = "kompas_connect")]
[Description("Подключиться к КОМПАС-3D: присоединиться к запущенному экземпляру или запустить новый, показать окно. Возвращает версию и редакцию.")]
@@ -41,7 +42,30 @@ public sealed class SystemTools(KompasSession session, AutoValidation autoValida
return (session.IsConnected && session.LastInfo is { } i
? $"Подключено: КОМПАС {i.Version} ({i.Edition})."
: "Не подключено. Вызовите kompas_connect.") + " " + validation;
: "Не подключено. Вызовите kompas_connect.") + " " + validation + " " + operationLog.Describe();
}
[McpServerTool(Name = "set_operation_log")]
[Description("Включить или выключить журнал операций: запись каждого вызова инструмента в файл JSONL " +
"(имя инструмента, аргументы, длительность, успех или текст ошибки, начало ответа). " +
"Журнал нужен для накопления истории работы — по ней видно, какие инструменты реально " +
"используются и на чём спотыкаются. Вызывайте по прямой просьбе пользователя; путь задаётся " +
"параметром path или переменной окружения KOMPAS_MCP_OPLOG_PATH (без пути журнал выключен). " +
"Снимки модели в журнал не пишутся — только их размер.")]
public string SetOperationLog(
[Description("true — включить запись, false — выключить")] bool enabled,
[Description("Файл журнала (.jsonl) или каталог для него. Пусто — прошлый путь либо KOMPAS_MCP_OPLOG_PATH")]
string? path = null)
{
if (!enabled)
{
operationLog.Disable();
return operationLog.Describe();
}
var file = operationLog.Enable(path);
return $"Журнал операций включён: {file}. Пишется по строке JSON на вызов инструмента " +
$"(записей за сессию: {operationLog.WrittenCount}).";
}
[McpServerTool(Name = "set_auto_validate")]
@@ -0,0 +1,121 @@
using System.Text.Json;
using Kompas.Mcp.Core.Diagnostics;
namespace Kompas.Mcp.Tests;
/// <summary>Формат строки журнала: одна строка JSON, кириллица не эскейпится, ответ усечён.</summary>
[Trait("Category", "Unit")]
public sealed class OperationLogEntryTests
{
private static OperationLogEntry Entry(
string tool = "extrude",
string? arguments = null,
bool ok = true,
string? result = null,
string? error = null)
=> new()
{
Timestamp = new DateTimeOffset(2026, 7, 31, 10, 20, 30, 456, TimeSpan.Zero),
Tool = tool,
Arguments = arguments is null ? null : Parse(arguments),
Duration = TimeSpan.FromMilliseconds(412),
Ok = ok,
Result = result,
Error = error,
};
private static Dictionary<string, JsonElement> Parse(string json)
=> JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json)!;
private static JsonElement Json(OperationLogEntry entry)
=> JsonDocument.Parse(entry.ToJson()).RootElement;
[Fact]
public void Writes_single_line()
{
var json = Entry(result: "Построено.\nВторая строка.").ToJson();
Assert.DoesNotContain('\n', json);
Assert.DoesNotContain('\r', json);
}
[Fact]
public void Keeps_core_fields()
{
var root = Json(Entry(result: "Готово."));
Assert.Equal("2026-07-31T10:20:30.456Z", root.GetProperty("ts").GetString());
Assert.Equal("extrude", root.GetProperty("tool").GetString());
Assert.Equal(412, root.GetProperty("ms").GetInt32());
Assert.True(root.GetProperty("ok").GetBoolean());
Assert.Equal("Готово.", root.GetProperty("result").GetString());
}
[Fact]
public void Cyrillic_is_not_escaped()
=> Assert.Contains("Готово", Entry(result: "Готово.").ToJson());
[Fact]
public void Arguments_are_kept_as_json()
{
var root = Json(Entry(arguments: """{"depth":20,"mode":"boss","draft":false}"""));
var args = root.GetProperty("args");
Assert.Equal(20, args.GetProperty("depth").GetInt32());
Assert.Equal("boss", args.GetProperty("mode").GetString());
Assert.False(args.GetProperty("draft").GetBoolean());
}
[Fact]
public void Empty_arguments_are_omitted()
=> Assert.False(Json(Entry(arguments: "{}")).TryGetProperty("args", out _));
[Fact]
public void Huge_arguments_are_truncated_to_string()
{
var text = new string('ш', OperationLogEntry.ArgumentsLimit + 100);
var arguments = JsonSerializer.Serialize(new Dictionary<string, string> { ["text"] = text });
var args = Json(Entry(arguments: arguments)).GetProperty("args");
Assert.Equal(JsonValueKind.String, args.ValueKind);
Assert.Contains("симв.]", args.GetString());
}
[Fact]
public void Long_result_is_truncated_but_length_is_kept()
{
var text = new string('x', OperationLogEntry.ResultLimit + 250);
var root = Json(Entry(result: text));
Assert.StartsWith(new string('x', OperationLogEntry.ResultLimit), root.GetProperty("result").GetString());
Assert.Contains("[+250 симв.]", root.GetProperty("result").GetString());
Assert.Equal(text.Length, root.GetProperty("resultChars").GetInt32());
}
[Fact]
public void Error_is_kept_whole_and_replaces_result()
{
var message = "Инструмент «extrude» не выполнен: " + new string('п', OperationLogEntry.ResultLimit + 50);
var root = Json(Entry(ok: false, result: "не должно попасть", error: message));
Assert.False(root.GetProperty("ok").GetBoolean());
Assert.Equal(message, root.GetProperty("error").GetString());
Assert.False(root.TryGetProperty("result", out _));
}
[Fact]
public void Session_and_client_are_optional()
{
var root = Json(Entry());
Assert.False(root.TryGetProperty("session", out _));
Assert.False(root.TryGetProperty("client", out _));
}
[Fact]
public void Truncate_leaves_short_text_alone()
=> Assert.Equal("коротко", OperationLogEntry.Truncate("коротко", 100));
}
@@ -0,0 +1,74 @@
using Kompas.Mcp.Core.Diagnostics;
namespace Kompas.Mcp.Tests;
/// <summary>Настройки журнала операций: без пути — выключен, путь-каталог получает имя файла.</summary>
[Trait("Category", "Unit")]
public sealed class OperationLogSettingsTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Disabled_without_path(string? path)
{
var options = OperationLogSettings.Parse(enabled: "1", path: path);
Assert.False(options.Enabled);
Assert.Null(options.Path);
}
[Theory]
[InlineData("1")]
[InlineData("")]
[InlineData(null)]
[InlineData("что угодно")]
public void Enabled_when_path_set(string? enabled)
=> Assert.True(OperationLogSettings.Parse(enabled, Path.Combine("C:", "logs", "ops.jsonl")).Enabled);
[Theory]
[InlineData("0")]
[InlineData("false")]
[InlineData("OFF")]
[InlineData("no")]
[InlineData("disabled")]
[InlineData(" 0 ")]
public void Switch_off_keeps_path_but_disables(string enabled)
{
var file = Path.Combine("C:", "logs", "ops.jsonl");
var options = OperationLogSettings.Parse(enabled, file);
Assert.False(options.Enabled);
Assert.Equal(file, options.Path);
}
[Fact]
public void File_path_is_kept_as_is()
{
var file = Path.Combine("C:", "logs", "ops.jsonl");
Assert.Equal(file, OperationLogSettings.ResolveFile(file));
}
[Fact]
public void Directory_path_gets_default_file_name()
{
var directory = Path.Combine("C:", "logs");
Assert.Equal(Path.Combine(directory, OperationLogSettings.DefaultFileName),
OperationLogSettings.ResolveFile(directory));
}
[Fact]
public void Trailing_separator_means_directory()
{
var directory = Path.Combine("C:", "logs") + Path.DirectorySeparatorChar;
Assert.Equal(Path.Combine(directory, OperationLogSettings.DefaultFileName),
OperationLogSettings.ResolveFile(directory));
}
[Fact]
public void Path_is_trimmed()
=> Assert.Equal("ops.jsonl", OperationLogSettings.ResolveFile(" ops.jsonl "));
}
+164
View File
@@ -0,0 +1,164 @@
using System.Text.Json;
using Kompas.Mcp.Core.Diagnostics;
namespace Kompas.Mcp.Tests;
/// <summary>Журнал операций: пишет JSONL только при включённом режиме и не роняет вызовы при сбоях.</summary>
[Trait("Category", "Unit")]
public sealed class OperationLogTests : IDisposable
{
private readonly string _directory =
Path.Combine(Path.GetTempPath(), "kompas-mcp-oplog-" + Guid.NewGuid().ToString("N")[..8]);
private string File(string name = "ops.jsonl") => Path.Combine(_directory, name);
public void Dispose()
{
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
private static OperationLogEntry Entry(string tool = "extrude", bool ok = true, string? result = "Готово.")
=> new()
{
Timestamp = DateTimeOffset.UtcNow,
Tool = tool,
Duration = TimeSpan.FromMilliseconds(10),
Ok = ok,
Result = result,
};
[Fact]
public void Disabled_by_default()
{
var log = new OperationLog(new OperationLogOptions(Enabled: false, Path: File()));
log.Write(Entry());
Assert.False(log.Enabled);
Assert.False(System.IO.File.Exists(File()));
}
[Fact]
public void Writes_one_line_per_call()
{
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: File()));
log.Write(Entry("extrude"));
log.Write(Entry("fillet_edge"));
var lines = System.IO.File.ReadAllLines(File());
Assert.Equal(2, lines.Length);
Assert.Equal("extrude", JsonDocument.Parse(lines[0]).RootElement.GetProperty("tool").GetString());
Assert.Equal("fillet_edge", JsonDocument.Parse(lines[1]).RootElement.GetProperty("tool").GetString());
Assert.Equal(2, log.WrittenCount);
}
[Fact]
public void Creates_missing_directory()
{
var nested = Path.Combine(_directory, "deep", "ops.jsonl");
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: nested));
log.Write(Entry());
Assert.True(System.IO.File.Exists(nested));
Assert.Equal(Path.GetFullPath(nested), log.Path);
}
[Fact]
public void Appends_to_existing_file()
{
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: File()));
log.Write(Entry("extrude"));
var second = new OperationLog(new OperationLogOptions(Enabled: true, Path: File()));
second.Write(Entry("hole"));
Assert.Equal(2, System.IO.File.ReadAllLines(File()).Length);
}
[Fact]
public void Disable_stops_writing_and_keeps_path()
{
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: File()));
log.Write(Entry());
log.Disable();
log.Write(Entry());
Assert.Single(System.IO.File.ReadAllLines(File()));
Assert.False(log.Enabled);
Assert.Equal(Path.GetFullPath(File()), log.Path);
}
[Fact]
public void Enable_reuses_remembered_path()
{
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: File()));
log.Disable();
var target = log.Enable();
log.Write(Entry());
Assert.Equal(Path.GetFullPath(File()), target);
Assert.Single(System.IO.File.ReadAllLines(File()));
}
[Fact]
public void Enable_switches_file()
{
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: File()));
log.Write(Entry());
log.Enable(File("other.jsonl"));
log.Write(Entry());
Assert.Single(System.IO.File.ReadAllLines(File()));
Assert.Single(System.IO.File.ReadAllLines(File("other.jsonl")));
}
[Fact]
public void Enable_without_any_path_explains_itself()
{
var log = new OperationLog(new OperationLogOptions(Enabled: false, Path: null));
var ex = Assert.Throws<ArgumentException>(() => log.Enable());
Assert.Contains(OperationLogSettings.PathVariable, ex.Message);
Assert.False(log.Enabled);
}
[Fact]
public void Bad_path_disables_log_instead_of_throwing()
{
// Каталог на месте файла: запись невозможна, но конструктор обязан отдать выключенный журнал.
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: Path.Combine(_directory, "x") + Path.DirectorySeparatorChar));
Assert.False(log.Enabled);
Assert.NotNull(log.LastError);
}
[Fact]
public void Write_failure_is_swallowed_and_remembered()
{
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: File()));
System.IO.File.Delete(File());
Directory.CreateDirectory(File()); // файл занят каталогом — AppendAllText упадёт
log.Write(Entry());
Assert.NotNull(log.LastError);
Assert.Equal(0, log.WrittenCount);
}
[Fact]
public void Describe_mentions_state()
{
var log = new OperationLog(new OperationLogOptions(Enabled: true, Path: File()));
Assert.Contains(File(), log.Describe());
log.Disable();
Assert.Contains("выключен", log.Describe());
}
}
+118
View File
@@ -0,0 +1,118 @@
#requires -Version 7.0
<#
.SYNOPSIS
Сводка по журналу операций MCP-сервера (JSONL): чем пользуются, что падает, что тормозит.
.DESCRIPTION
Журнал накапливает вызовы инструментов (см. set_operation_log и KOMPAS_MCP_OPLOG_PATH). Этот
отчёт — то, ради чего он ведётся: по нему видно, какие инструменты каталога реально зовут,
какие стабильно отказывают (кандидаты на доработку) и какие не зовут никогда (кандидаты на
удаление). Инструменты, ни разу не встретившиеся в журнале, перечисляются отдельно — их список
берётся из README (каталог инструментов там источник истины).
.EXAMPLE
pwsh -NoProfile -File tools/dev/oplog-report.ps1
.EXAMPLE
pwsh -NoProfile -File tools/dev/oplog-report.ps1 -Path D:\logs\kompas-operations.jsonl -Errors 5
#>
[CmdletBinding()]
param(
# Файл журнала или каталог с ним. По умолчанию — KOMPAS_MCP_OPLOG_PATH.
[string] $Path = $env:KOMPAS_MCP_OPLOG_PATH,
# Сколько текстов ошибок показать в разделе «частые отказы».
[int] $Errors = 10
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if ([string]::IsNullOrWhiteSpace($Path)) {
throw "Путь к журналу не задан: укажите -Path или переменную окружения KOMPAS_MCP_OPLOG_PATH."
}
if (Test-Path -LiteralPath $Path -PathType Container) {
$Path = Join-Path $Path 'kompas-operations.jsonl'
}
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
throw "Журнал не найден: $Path"
}
# Битая строка (сервер убит посреди записи) не должна ронять отчёт.
$broken = 0
$records = foreach ($line in (Get-Content -LiteralPath $Path -Encoding utf8)) {
if ([string]::IsNullOrWhiteSpace($line)) { continue }
try { $line | ConvertFrom-Json } catch { $broken++ }
}
$records = @($records)
if ($records.Count -eq 0) {
Write-Host "Журнал пуст: $Path"
return
}
$sessions = @($records | Where-Object { $_.PSObject.Properties.Name -contains 'session' } |
Select-Object -ExpandProperty session -Unique)
$failed = @($records | Where-Object { -not $_.ok })
Write-Host "Журнал: $Path"
Write-Host ("Вызовов: {0} отказов: {1} ({2:P1}) период: {3} … {4}" -f
$records.Count, $failed.Count, ($failed.Count / $records.Count),
$records[0].ts, $records[-1].ts)
if ($sessions.Count -gt 0) { Write-Host "Сессий: $($sessions.Count)" }
if ($broken -gt 0) { Write-Host "Нечитаемых строк: $broken" -ForegroundColor DarkYellow }
Write-Host "`n=== Инструменты ==="
$byTool = $records | Group-Object tool | ForEach-Object {
$calls = @($_.Group)
$bad = @($calls | Where-Object { -not $_.ok })
$ms = @($calls | ForEach-Object { [double] $_.ms } | Sort-Object)
[pscustomobject]@{
Инструмент = $_.Name
Вызовов = $calls.Count
Отказов = $bad.Count
'Отказы %' = [int] [math]::Round(100 * $bad.Count / $calls.Count)
'мс med' = [int] $ms[[math]::Floor($ms.Count / 2)]
'мс max' = [int] ($ms | Select-Object -Last 1)
}
}
$byTool | Sort-Object Вызовов -Descending | Format-Table -AutoSize
$suspects = @($byTool | Where-Object { $_.Отказов -gt 0 } | Sort-Object 'Отказы %' -Descending)
if ($suspects.Count -gt 0) {
Write-Host "=== Кандидаты на доработку (есть отказы) ==="
$suspects | Format-Table -AutoSize
}
if ($failed.Count -gt 0) {
Write-Host "=== Частые отказы ==="
$failed |
Group-Object { "$($_.tool): " + ($_.error -replace '\s+', ' ') } |
Sort-Object Count -Descending |
Select-Object -First $Errors |
ForEach-Object {
$text = $_.Name
if ($text.Length -gt 160) { $text = $text.Substring(0, 160) + '…' }
Write-Host ("{0,4}x {1}" -f $_.Count, $text)
}
}
# Не звали ни разу. Каталог берётся из исходников (атрибуты McpServerTool), а не из README:
# имя инструмента там объявлено буквально, без риска поймать в шаблон параметр или слово из текста.
$toolsDir = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) 'src\Kompas.Mcp.Host\Tools'
if (Test-Path -LiteralPath $toolsDir) {
$catalog = [System.Collections.Generic.HashSet[string]]::new()
foreach ($file in (Get-ChildItem -LiteralPath $toolsDir -Filter '*.cs')) {
$text = Get-Content -LiteralPath $file.FullName -Raw -Encoding utf8
foreach ($m in [regex]::Matches($text, 'McpServerTool\(Name\s*=\s*"([^"]+)"')) {
[void] $catalog.Add($m.Groups[1].Value)
}
}
$known = $catalog.Count
foreach ($tool in ($records | Select-Object -ExpandProperty tool -Unique)) { [void] $catalog.Remove($tool) }
Write-Host "`n=== Ни разу не встречались в журнале: $($catalog.Count) из $known ==="
if ($catalog.Count -gt 0) { Write-Host (($catalog | Sort-Object) -join ', ') }
}