diff --git a/src/Kompas.Mcp.Core/Interop/NativeMethods.cs b/src/Kompas.Mcp.Core/Interop/NativeMethods.cs
new file mode 100644
index 0000000..0087575
--- /dev/null
+++ b/src/Kompas.Mcp.Core/Interop/NativeMethods.cs
@@ -0,0 +1,40 @@
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+
+namespace Kompas.Mcp.Core.Interop;
+
+///
+/// Нативные функции для attach к уже запущенному COM-серверу.
+/// В .NET 5+ Marshal.GetActiveObject отсутствует, поэтому используем
+/// CLSIDFromProgID (ole32) + GetActiveObject (oleaut32) напрямую.
+///
+[SupportedOSPlatform("windows")]
+internal static class NativeMethods
+{
+ [DllImport("ole32.dll", PreserveSig = false)]
+ private static extern void CLSIDFromProgID(
+ [MarshalAs(UnmanagedType.LPWStr)] string progId, out Guid clsid);
+
+ [DllImport("oleaut32.dll", PreserveSig = false)]
+ private static extern void GetActiveObject(
+ ref Guid rclsid, IntPtr pvReserved,
+ [MarshalAs(UnmanagedType.IUnknown)] out object ppunk);
+
+ ///
+ /// Подключиться к запущенному экземпляру COM-сервера по ProgID.
+ /// Возвращает null, если ProgID не зарегистрирован или экземпляр не запущен.
+ ///
+ public static object? GetActiveObject(string progId)
+ {
+ try
+ {
+ CLSIDFromProgID(progId, out Guid clsid);
+ GetActiveObject(ref clsid, IntPtr.Zero, out object obj);
+ return obj;
+ }
+ catch (COMException)
+ {
+ return null; // MK_E_UNAVAILABLE / CO_E_CLASSSTRING и т.п.
+ }
+ }
+}
diff --git a/src/Kompas.Mcp.Core/KompasConnectionInfo.cs b/src/Kompas.Mcp.Core/KompasConnectionInfo.cs
new file mode 100644
index 0000000..28997a2
--- /dev/null
+++ b/src/Kompas.Mcp.Core/KompasConnectionInfo.cs
@@ -0,0 +1,20 @@
+namespace Kompas.Mcp.Core;
+
+/// Сведения об установленном подключении к КОМПАС.
+public sealed record KompasConnectionInfo
+{
+ /// ProgID, по которому удалось подключиться (например, KOMPAS.Application.5).
+ public required string ProgId { get; init; }
+
+ /// true — экземпляр КОМПАС запущен этим процессом; false — присоединились к работающему.
+ public required bool WasLaunched { get; init; }
+
+ /// Версия системы в формате Major.Minor.Release.
+ public required string Version { get; init; }
+
+ /// Номер сборки внутри версии.
+ public required int BuildNumber { get; init; }
+
+ /// Редакция (по ProgID): "Home/LT" для KOMPASLT.*, иначе "Full".
+ public required string Edition { get; init; }
+}
diff --git a/src/Kompas.Mcp.Core/KompasSession.cs b/src/Kompas.Mcp.Core/KompasSession.cs
new file mode 100644
index 0000000..4988da9
--- /dev/null
+++ b/src/Kompas.Mcp.Core/KompasSession.cs
@@ -0,0 +1,154 @@
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+using Kompas.Mcp.Core.Interop;
+using Kompas.Mcp.Core.Threading;
+using Kompas6API5;
+using KompasAPI7;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Kompas.Mcp.Core;
+
+///
+/// Подключение к КОМПАС и владение корневыми COM-объектами на время жизни сервера.
+///
+/// Подключается через API5 (), так как он нужен для построения 3D
+/// (ksPart) и снимков (ksDocument3D), а из него получает современный API7 ().
+/// Все обращения к COM выполняются на STA-потоке .
+///
+///
+[SupportedOSPlatform("windows")]
+public sealed class KompasSession : IDisposable
+{
+ // Порядок попыток. Нужен KompasObject (API5), поэтому .7 не используем как точку входа.
+ // Проверено на v24 Home: зарегистрирован KOMPAS.Application.5 (не LT).
+ private static readonly string[] ProgIds =
+ {
+ "KOMPAS.Application.5",
+ "KOMPASLT.Application.5",
+ };
+
+ private readonly KompasDispatcher _dispatcher;
+ private readonly ILogger _log;
+
+ private KompasObject? _kompas; // API5 root
+ private IApplication? _app; // API7 application
+ private bool _launchedByUs;
+
+ public KompasSession(KompasDispatcher dispatcher, ILogger? log = null)
+ {
+ _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
+ _log = log ?? NullLogger.Instance;
+ }
+
+ public bool IsConnected => _app is not null;
+
+ /// Корневой объект API5. Доступен только на STA-потоке после подключения.
+ public KompasObject Kompas =>
+ _kompas ?? throw new InvalidOperationException("Нет подключения к КОМПАС. Сначала вызовите ConnectAsync.");
+
+ /// Приложение API7. Доступно только на STA-потоке после подключения.
+ public IApplication Application =>
+ _app ?? throw new InvalidOperationException("Нет подключения к КОМПАС. Сначала вызовите ConnectAsync.");
+
+ /// Подключиться к КОМПАС: attach к запущенному или launch нового экземпляра.
+ public Task ConnectAsync(bool visible = true, CancellationToken ct = default)
+ => _dispatcher.InvokeAsync(() => ConnectCore(visible), ct);
+
+ private KompasConnectionInfo ConnectCore(bool visible)
+ {
+ if (_app is not null)
+ return BuildInfo(LastProgId);
+
+ // 1) attach к уже запущенному экземпляру
+ foreach (var id in ProgIds)
+ {
+ if (NativeMethods.GetActiveObject(id) is KompasObject ko)
+ {
+ _kompas = ko;
+ LastProgId = id;
+ _launchedByUs = false;
+ _log.LogInformation("Присоединились к запущенному КОМПАС ({ProgId})", id);
+ break;
+ }
+ }
+
+ // 2) запустить новый экземпляр
+ if (_kompas is null)
+ {
+ foreach (var id in ProgIds)
+ {
+ var type = Type.GetTypeFromProgID(id, throwOnError: false);
+ if (type is null) continue;
+ try
+ {
+ if (Activator.CreateInstance(type) is KompasObject ko)
+ {
+ _kompas = ko;
+ LastProgId = id;
+ _launchedByUs = true;
+ _log.LogInformation("Запустили новый КОМПАС ({ProgId})", id);
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ _log.LogWarning(ex, "Не удалось создать экземпляр {ProgId}", id);
+ }
+ }
+ }
+
+ if (_kompas is null)
+ throw new InvalidOperationException(
+ "Не удалось подключиться к КОМПАС. Убедитесь, что КОМПАС-3D установлен и запускается.");
+
+ _app = (IApplication?)_kompas.ksGetApplication7()
+ ?? throw new InvalidOperationException("ksGetApplication7() вернул null — API7 недоступен.");
+
+ _app.Visible = visible;
+
+ return BuildInfo(LastProgId);
+ }
+
+ private string LastProgId { get; set; } = string.Empty;
+
+ private KompasConnectionInfo BuildInfo(string progId)
+ {
+ int major = 0, minor = 0, release = 0, build = 0;
+ try { _app!.GetSystemVersion(out major, out minor, out release, out build); }
+ catch (Exception ex) { _log.LogWarning(ex, "GetSystemVersion недоступен"); }
+
+ int buildNumber = 0;
+ try { buildNumber = _app!.BuildNumber; }
+ catch (Exception ex) { _log.LogWarning(ex, "BuildNumber недоступен"); }
+
+ return new KompasConnectionInfo
+ {
+ ProgId = progId,
+ WasLaunched = _launchedByUs,
+ Version = $"{major}.{minor}.{release}",
+ BuildNumber = buildNumber,
+ Edition = progId.StartsWith("KOMPASLT", StringComparison.OrdinalIgnoreCase) ? "Home/LT" : "Full",
+ };
+ }
+
+ public void Dispose()
+ {
+ // Освобождаем COM-объекты на том же STA-потоке, на котором они созданы.
+ try
+ {
+ _dispatcher.InvokeAsync(() =>
+ {
+ // Quit не вызываем: не «убиваем» КОМПАС пользователя (см. docs/OPEN_QUESTIONS.md).
+ if (_app is not null && Marshal.IsComObject(_app)) Marshal.ReleaseComObject(_app);
+ if (_kompas is not null && Marshal.IsComObject(_kompas)) Marshal.ReleaseComObject(_kompas);
+ _app = null;
+ _kompas = null;
+ }).GetAwaiter().GetResult();
+ }
+ catch (ObjectDisposedException)
+ {
+ // Диспетчер уже остановлен — COM освободит GC-финализатор.
+ }
+ }
+}
diff --git a/src/Kompas.Mcp.Core/Threading/KompasDispatcher.cs b/src/Kompas.Mcp.Core/Threading/KompasDispatcher.cs
new file mode 100644
index 0000000..22e5d62
--- /dev/null
+++ b/src/Kompas.Mcp.Core/Threading/KompasDispatcher.cs
@@ -0,0 +1,111 @@
+using System.Collections.Concurrent;
+using System.Runtime.Versioning;
+
+namespace Kompas.Mcp.Core.Threading;
+
+///
+/// Выделенный STA-поток, который владеет всеми COM-вызовами к КОМПАС.
+///
+/// КОМПАС — однопоточный STA COM-сервер в единственном экземпляре, поэтому весь доступ
+/// к нему сериализуется через этот поток. Асинхронные обработчики MCP-инструментов
+/// маршалят работу сюда через .
+///
+///
+/// v1: простая очередь задач без явного насоса сообщений Windows. Для исходящих
+/// синхронных вызовов из out-of-process клиента этого достаточно (COM сам обслуживает
+/// STA на время вызова). Если при интеграции появятся зависания на колбэках/диалогах —
+/// заменить цикл на насос сообщений (WPF Dispatcher / нативный GetMessage). См. docs/OPEN_QUESTIONS.md.
+///
+///
+[SupportedOSPlatform("windows")]
+public sealed class KompasDispatcher : IDisposable
+{
+ private readonly Thread _thread;
+ private readonly BlockingCollection _queue = new();
+ private readonly ManualResetEventSlim _ready = new(false);
+ private volatile bool _disposed;
+
+ public KompasDispatcher()
+ {
+ _thread = new Thread(ThreadProc)
+ {
+ IsBackground = true,
+ Name = "KompasSTA",
+ };
+ _thread.SetApartmentState(ApartmentState.STA);
+ _thread.Start();
+ _ready.Wait();
+ }
+
+ /// Выполняется ли вызывающий код уже на STA-потоке диспетчера.
+ public bool IsOnDispatcherThread => Thread.CurrentThread == _thread;
+
+ /// Поставить функцию в очередь STA-потока и дождаться результата.
+ public Task InvokeAsync(Func func, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(func);
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ if (_disposed)
+ {
+ tcs.SetException(new ObjectDisposedException(nameof(KompasDispatcher)));
+ return tcs.Task;
+ }
+
+ // Уже на STA-потоке — выполняем синхронно, иначе будет дедлок (ждать себя же).
+ if (IsOnDispatcherThread)
+ {
+ try { tcs.SetResult(func()); }
+ catch (Exception ex) { tcs.SetException(ex); }
+ return tcs.Task;
+ }
+
+ try
+ {
+ _queue.Add(() =>
+ {
+ if (ct.IsCancellationRequested) { tcs.TrySetCanceled(ct); return; }
+ try { tcs.TrySetResult(func()); }
+ catch (Exception ex) { tcs.TrySetException(ex); }
+ });
+ }
+ catch (InvalidOperationException)
+ {
+ tcs.SetException(new ObjectDisposedException(nameof(KompasDispatcher)));
+ }
+
+ return tcs.Task;
+ }
+
+ /// Поставить действие в очередь STA-потока и дождаться завершения.
+ public Task InvokeAsync(Action action, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(action);
+ return InvokeAsync(() => { action(); return true; }, ct);
+ }
+
+ private void ThreadProc()
+ {
+ _ready.Set();
+ foreach (var work in _queue.GetConsumingEnumerable())
+ {
+ // Исключения каждой задачи уже перехвачены и переданы в её TaskCompletionSource.
+ work();
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+
+ _queue.CompleteAdding();
+ if (!_thread.Join(TimeSpan.FromSeconds(10)))
+ {
+ // STA-поток не завершился (возможно, висит COM-вызов) — оставляем как фон.
+ }
+
+ _queue.Dispose();
+ _ready.Dispose();
+ }
+}
diff --git a/tests/Kompas.Mcp.Tests/DispatcherTests.cs b/tests/Kompas.Mcp.Tests/DispatcherTests.cs
new file mode 100644
index 0000000..5d6af39
--- /dev/null
+++ b/tests/Kompas.Mcp.Tests/DispatcherTests.cs
@@ -0,0 +1,54 @@
+using Kompas.Mcp.Core.Threading;
+
+namespace Kompas.Mcp.Tests;
+
+/// Юнит-тесты STA-диспетчера. COM не требуется.
+[Trait("Category", "Unit")]
+public sealed class DispatcherTests
+{
+ [Fact]
+ public async Task Runs_work_on_STA_thread()
+ {
+ using var d = new KompasDispatcher();
+ var state = await d.InvokeAsync(() => Thread.CurrentThread.GetApartmentState());
+ Assert.Equal(ApartmentState.STA, state);
+ }
+
+ [Fact]
+ public async Task Serializes_all_work_on_one_thread()
+ {
+ using var d = new KompasDispatcher();
+ var id1 = await d.InvokeAsync(() => Environment.CurrentManagedThreadId);
+ var id2 = await d.InvokeAsync(() => Environment.CurrentManagedThreadId);
+ Assert.Equal(id1, id2);
+ Assert.NotEqual(Environment.CurrentManagedThreadId, id1);
+ }
+
+ [Fact]
+ public async Task Propagates_exceptions_to_caller()
+ {
+ using var d = new KompasDispatcher();
+ await Assert.ThrowsAsync(
+ () => d.InvokeAsync(() => throw new InvalidOperationException("boom")));
+ }
+
+ [Fact]
+ public async Task Throws_ObjectDisposed_after_dispose()
+ {
+ var d = new KompasDispatcher();
+ d.Dispose();
+ await Assert.ThrowsAsync(() => d.InvokeAsync(() => { }));
+ }
+
+ [Fact]
+ public async Task Nested_invoke_runs_inline_without_deadlock()
+ {
+ using var d = new KompasDispatcher();
+ var result = await d.InvokeAsync(() =>
+ {
+ // Вложенный вызов на том же потоке должен выполниться синхронно, без дедлока.
+ return d.InvokeAsync(() => 42).GetAwaiter().GetResult();
+ });
+ Assert.Equal(42, result);
+ }
+}
diff --git a/tests/Kompas.Mcp.Tests/Integration/ConnectionTests.cs b/tests/Kompas.Mcp.Tests/Integration/ConnectionTests.cs
new file mode 100644
index 0000000..2d0da18
--- /dev/null
+++ b/tests/Kompas.Mcp.Tests/Integration/ConnectionTests.cs
@@ -0,0 +1,31 @@
+namespace Kompas.Mcp.Tests.Integration;
+
+/// Интеграция: подключение к КОМПАС. Требует установленный КОМПАС-3D.
+[Trait("Category", "Integration")]
+[Collection(KompasCollection.Name)]
+public sealed class ConnectionTests
+{
+ private readonly KompasFixture _fx;
+
+ public ConnectionTests(KompasFixture fx) => _fx = fx;
+
+ [Fact]
+ public void Connects_to_kompas()
+ {
+ Assert.True(_fx.Session.IsConnected);
+ }
+
+ [Fact]
+ public void Reports_version_24()
+ {
+ Assert.False(string.IsNullOrWhiteSpace(_fx.Info.Version));
+ Assert.StartsWith("24", _fx.Info.Version);
+ }
+
+ [Fact]
+ public void Reports_progid_and_edition()
+ {
+ Assert.Contains("Application.5", _fx.Info.ProgId);
+ Assert.False(string.IsNullOrWhiteSpace(_fx.Info.Edition));
+ }
+}
diff --git a/tests/Kompas.Mcp.Tests/Integration/KompasFixture.cs b/tests/Kompas.Mcp.Tests/Integration/KompasFixture.cs
new file mode 100644
index 0000000..de18c9b
--- /dev/null
+++ b/tests/Kompas.Mcp.Tests/Integration/KompasFixture.cs
@@ -0,0 +1,35 @@
+using Kompas.Mcp.Core;
+using Kompas.Mcp.Core.Threading;
+
+namespace Kompas.Mcp.Tests.Integration;
+
+///
+/// Общая фикстура интеграционных тестов: один STA-диспетчер и одно подключение к КОМПАС
+/// на весь набор тестов коллекции (КОМПАС присоединяется/запускается единожды).
+///
+public sealed class KompasFixture : IDisposable
+{
+ public KompasDispatcher Dispatcher { get; }
+ public KompasSession Session { get; }
+ public KompasConnectionInfo Info { get; }
+
+ public KompasFixture()
+ {
+ Dispatcher = new KompasDispatcher();
+ Session = new KompasSession(Dispatcher);
+ Info = Session.ConnectAsync(visible: true).GetAwaiter().GetResult();
+ }
+
+ public void Dispose()
+ {
+ Session.Dispose();
+ Dispatcher.Dispose();
+ }
+}
+
+/// Коллекция, позволяющая делить одно подключение между классами интеграционных тестов.
+[CollectionDefinition(Name)]
+public sealed class KompasCollection : ICollectionFixture
+{
+ public const string Name = "kompas";
+}
diff --git a/tests/Kompas.Mcp.Tests/UnitTest1.cs b/tests/Kompas.Mcp.Tests/UnitTest1.cs
deleted file mode 100644
index c583d30..0000000
--- a/tests/Kompas.Mcp.Tests/UnitTest1.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace Kompas.Mcp.Tests;
-
-public class UnitTest1
-{
- [Fact]
- public void Test1()
- {
-
- }
-}