diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e809935 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.pyc +_test_copy.xml +*.log +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc38ca2 --- /dev/null +++ b/README.md @@ -0,0 +1,174 @@ +# dkc-ramcube — MCP-сервер: каталог ДКС + проект RamCube + +Локальный MCP-сервер (stdio) для проектирования НКУ на оборудовании ДКС. +Связывает три вещи, которые до этого приходилось сводить руками: + +1. **каталог ДКС** — база `DKC_data.mdf` из установки RAMcube (30 096 позиций, + параметры, цены, габариты, 3D-геометрия); +2. **однолинейную схему** — разбор PDF-листа (перечень аппаратов, параметры из + примечаний); +3. **проект RamCube** — XML-файл проекта: чтение состава и габарита, замена + аппаратов, параметры проекта. + +Сервер ничего не делает «за программу»: компоновку, панели и выгрузку документов +считает сам RamCube. Задача сервера — подготовить и проверить вход (схему) и +прочитать результат (габарит, панели). + +## Возможности + +| Инструмент | Что делает | +|---|---| +| `dkc_search` | поиск аппаратов по артикулу и номинальному току; возвращает deviceId, In, Icu, число полюсов, исполнение, габарит, семейство | +| `dkc_device` | полные параметры позиции по `deviceId` (это же значение стоит в XML как `elementDevice`) | +| `ramcube_project_read` | сводка проекта: параметры, аппараты схемы с кодами и позициями, панели, длина ряда (всего / полезные / служебные) | +| `ramcube_apparatus_replace` | замена аппаратов: правит `elementDevice`, 3D-модель и **оба** набора свойств, затем сам перечитывает файл и отдаёт проверку | +| `ramcube_set_project` | параметры проекта: имя, IP, секционирование (3b/4b), подвод кабелей (сверху/снизу), Iном, Iкз, наличие АВР | + +### Почему замена аппарата — не «поменять один атрибут» + +В XML проекта RamCube свойства аппарата лежат **в двух местах**: + +- эталон элемента — `propertyValueInstance` = атрибут `elementInstance` самого + `` (порядка 25 значений); +- набор каждого размещённого экземпляра — `propertyValueInstance` = + `elementInstanceInstance` (порядка 67 значений). + +Если поправить только `elementDevice`, программа при загрузке выдаёт +«Ошибки в проекте: … не соответствует … [device:…]» и в спецификации остаётся +старый код. Инструмент правит оба набора и возвращает `verified_instances` — +фактические значения после записи. + +## Требования + +- Windows (нужны LocalDB и установленный RAMcube с базой каталога); +- Python 3.12 (в `.venv`); +- доступ к базе каталога: копия `DKC_data.mdf`, подключённая к LocalDB + (см. «Подготовка базы»). + +## Установка + +```powershell +git clone dkc-ramcube +cd dkc-ramcube +uv venv --python 3.12 .venv +uv pip install --python .venv\Scripts\python.exe -r requirements.txt +``` + +`requirements.txt`: + +``` +mcp<2 +pyodbc==5.1.0 +``` + +Версии принципиальны: + +- `mcp<2` — в mcp 2.x `FastMCP` переименован в `MCPServer`, код сервера написан под 1.x; +- `pyodbc==5.1.0` — в 5.5.0 не загружается legacy-драйвер `{SQL Server}` + (ошибка «Не найден SQLSRV32.dll»). + +## Подготовка базы каталога + +1. Взять `RamcubeV3\DKC_data.mdf` и `DKC_data_log.ldf` из установки RAMcube + (`C:\Program Files (x86)\DKC\RAMcube V4.4.0`). Оригинал не трогать — работать + с копией. +2. Подключить копию к LocalDB, например как `DKC_cat_ro_v44`: + + ```powershell + SqlLocalDB.exe start MSSQLLocalDB + sqlcmd -S "(localdb)\MSSQLLocalDB" -Q "CREATE DATABASE DKC_cat_ro_v44 ON (FILENAME='C:\ramcube_db\DKC_cat_ro.mdf'),(FILENAME='C:\ramcube_db\DKC_cat_ro_log.ldf') FOR ATTACH" + ``` + +3. Проверить: `SELECT COUNT(*) FROM tblDevices` → 30 096. + +## Подключение к Hermes + +```powershell +hermes -p ai-si config set mcp_servers.dkc-ramcube.command "C:/path/to/dkc-ramcube/.venv/Scripts/python.exe" +hermes -p ai-si config set mcp_servers.dkc-ramcube.args '["C:/path/to/dkc-ramcube/server.py"]' +hermes -p ai-si config set mcp_servers.dkc-ramcube.enabled true +hermes -p ai-si mcp test dkc-ramcube +``` + +Инструменты появляются в новой сессии Hermes. + +## Переменные окружения + +| Переменная | Назначение | По умолчанию | +|---|---|---| +| `DKC_CONN` | готовая строка подключения; перебивает LocalDB | — | +| `DKC_DB` | имя базы каталога | `DKC_cat_ro_v44` | +| `DKC_LOCALDB` | путь к `SqlLocalDB.exe` | автопоиск старшей версии | +| `DKC_LOCALDB_INSTANCE` | имя инстанса | `MSSQLLocalDB` | + +## Примеры + +Поиск аппарата на 4000 А: + +```json +{"query": "NW40", "in_min": 3000} +``` + +→ `ACB NW40`, `device_id: 11919`, `in_a: 4000`, `icu_ka: "100"`, `W - выкатной`, +габарит 441×441×421, семейство `NW32-NW40`. + +Замена вводных в проекте: + +```json +{"xml_in": "vrsh1.xml", "xml_out": "vrsh1_new.xml", + "replacements": [{"element_id": "-174426", "new_device_id": 11919}]} +``` + +Вместо `element_id` можно указать `element_device` — тогда заменятся все элементы +с этим `deviceId`. + +Чтение габарита после компоновки: + +```json +{"xml_path": "vrsh1_new.xml"} +``` + +→ `length_mm: {total: 7054, useful: 4217, service: 2837}`, то есть полезные панели +отдельно от шинных опусков и кабельных отсеков, которые RamCube добавляет сам. + +## Модель данных (что читается из базы) + +- `tblDevices` — позиции каталога (`deviceId`, `Code`); +- `tblDeviceValues` + `tblDeviceParameters` — параметры позиции: `2` In, + `3` In расцепителя, `8` Icu, `9` Код, `10` полюса, `11` производитель, + `12` тип по каталогу, `43` исполнение; +- `tblDrawingUnits` — габариты (`unitBoundMinX…MaxZ`) и монтажные поля. + +Замечание: `deviceValueContent` имеет тип `sql_variant`, который pyodbc не читает +(ODBC type −150) — в запросах обязателен `CAST(... AS nvarchar(400))`. + +## Известные ограничения + +- **Новый щит «с нуля» не генерируется.** В базе пусты `tblElements`, + `tblElementComponents`, `tblElementConnectors`: компоненты и монтажные точки + создаёт само приложение при вставке аппарата из «Репозитория». Эталонный блок + берётся из проекта, куда аппарат вставлен штатно. +- **Замена надёжна внутри семейства**, где совпадают компоненты и модель + (пример: NW08…NW40 — одна линейка моделей, габарит в базе у всех 441×441×421). + Смена на чужое семейство даёт «Отсутствуют монтажные точки». +- **Компоновка и выгрузка документов — в GUI RamCube**, сервер их не запускает. +- **PDF-разбор схемы** в этой версии не входит: перечень аппаратов готовится + отдельно, сервер работает с каталогом и XML. + +## Структура репозитория + +``` +server.py — MCP-сервер (stdlib + pyodbc + mcp) +test_client.py — проверка: список инструментов и вызовы на копии проекта +requirements.txt — фиксированные версии зависимостей +README.md — это описание +``` + +## Проверка после установки + +```powershell +.venv\Scripts\python.exe test_client.py +``` + +Ожидаемо: 5 инструментов, `dkc_search` отдаёт позиции, `ramcube_apparatus_replace` +возвращает `verified_instances` с новым кодом и номиналом. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cf03d95 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +mcp<2 +pyodbc==5.1.0 diff --git a/server.py b/server.py new file mode 100644 index 0000000..66b46c7 --- /dev/null +++ b/server.py @@ -0,0 +1,387 @@ +# -*- coding: utf-8 -*- +"""MCP-сервер: каталог ДКС + проект RamCube (XML). + +Инструменты: + dkc_search — поиск аппаратов в каталоге ДКС (библиотека DKC_data.mdf) + dkc_device — полные параметры позиции + габарит + ramcube_project_read — сводка проекта RamCube из XML + ramcube_apparatus_replace — замена аппаратов в схеме XML (оба набора свойств) + ramcube_set_project — параметры проекта (IP, секционирование, ввод кабелей, Iном, Iкз) + +Запуск: python server.py (stdio) +""" +from __future__ import annotations + +import os +import re +import subprocess +import xml.etree.ElementTree as ET +from typing import Any, Literal + +import pyodbc +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("dkc-ramcube") + +def _find_localdb() -> str: + """SqlLocalDB.exe: берём из переменной окружения или ищем старшую версию.""" + env = os.environ.get("DKC_LOCALDB") + if env: + return env + base = r"C:\Program Files\Microsoft SQL Server" + found = [] + for name in os.listdir(base) if os.path.isdir(base) else []: + if not name.isdigit(): + continue + p = os.path.join(base, name, "Tools", "Binn", "SqlLocalDB.exe") + if os.path.exists(p): + found.append((int(name), p)) + if not found: + raise RuntimeError("SqlLocalDB.exe не найден: задайте DKC_LOCALDB") + return max(found)[1] + + +LOCALDB = _find_localdb() +INSTANCE = os.environ.get("DKC_LOCALDB_INSTANCE", "MSSQLLocalDB") +DB = os.environ.get("DKC_DB", "DKC_cat_ro_v44") +CONN_STR = os.environ.get("DKC_CONN", "") # готовые строки подключения: перебивает LocalDB + +# id параметров каталога (проверено по базе 4.4) +P_IN, P_IN_R, P_ICU, P_CODE, P_POLES = 2, 3, 8, 9, 10 +P_VENDOR, P_CAT_TYPE, P_DESIGN = 11, 12, 43 + +# модели 3D по семействам (пути внутри \dwg) +MODELS = { + "NW08-NW25": r".\Schneider Electric\Drawings\Models\3D\ACB\NW08-NW25 MTZ2 (630-2000) (W) 3p_3D.dwg", + "NW32-NW40": r".\Schneider Electric\Drawings\Models\3D\ACB\NW32-NW40 MTZ2 (3200-4000А) (W) 3p_3D.dwg", +} + + +# ---------------------------------------------------------------- БД каталога +_conn: pyodbc.Connection | None = None + + +def _db() -> pyodbc.Connection: + """Подключение к базе каталога через pipe LocalDB (драйвер {SQL Server}).""" + global _conn + if _conn is not None: + return _conn + # ODBC-драйвер {SQL Server} раскрывает %WINDIR%: в урезанном окружении stdio-процесса + # переменных может не быть — иначе ошибка IM003 «не найден SQLSRV32.dll». + os.environ.setdefault("SystemRoot", r"C:\Windows") + os.environ.setdefault("WINDIR", r"C:\Windows") + if CONN_STR: + _conn = pyodbc.connect(CONN_STR, timeout=30) + return _conn + subprocess.run([LOCALDB, "start", INSTANCE], capture_output=True, timeout=90) + info = subprocess.run([LOCALDB, "info", INSTANCE], capture_output=True, text=True, timeout=60).stdout + m = re.search(r"(np:\\.*?tsql\\query)", info) + if not m: + raise RuntimeError("LocalDB не запущен: нет instance pipe name") + _conn = pyodbc.connect( + "DRIVER={SQL Server};SERVER=%s;DATABASE=%s;Trusted_Connection=yes;" % (m.group(1), DB), + timeout=30, + ) + return _conn + + +def _params(device_id: int) -> dict[int, str]: + cur = _db().cursor() + q = """SELECT deviceValueParameter, CAST(deviceValueContent AS nvarchar(400)) + FROM tblDeviceValues WHERE deviceValueDevice = ?""" + # deviceValueContent — тип sql_variant, pyodbc его не читает (ODBC type -150): только через CAST + return {int(pid): ("" if val is None else str(val)) for pid, val in cur.execute(q, device_id)} + + +def _bounds(device_id: int) -> dict[str, float] | None: + cur = _db().cursor() + q = """SELECT unitBoundMinX, unitBoundMaxX, unitBoundMinY, unitBoundMaxY, + unitBoundMinZ, unitBoundMaxZ + FROM tblDrawingUnits WHERE unitDevice = ?""" + row = cur.execute(q, device_id).fetchone() + if not row: + return None + return {"width": round(row[1] - row[0], 1), "depth": round(row[3] - row[2], 1), "height": round(row[5] - row[4], 1)} + + +def _series(code: str) -> str | None: + """Семейство ACB по коду: ACB NW08..NW25 / ACB NW32..NW40.""" + m = re.search(r"NW(\d+)", code or "") + if not m: + return None + n = int(m.group(1)) + if 8 <= n <= 25: + return "NW08-NW25" + if 32 <= n <= 40: + return "NW32-NW40" + return None + + +# ---------------------------------------------------------------- инструменты +@mcp.tool() +def dkc_search(query: str = "", in_min: int | None = None, in_max: int | None = None, + with_geometry_only: bool = False, limit: int = 20) -> list[dict[str, Any]]: + """Поиск аппаратов в каталоге ДКС. + + query — подстрока артикула/кода (например 'NW40', 'MNX250', 'MD63N'); + in_min/in_max — фильтр по номинальному току In, А; + with_geometry_only — только позиции с 3D-геометрией (те, что встанут в сборку). + """ + cur = _db().cursor() + # без alias и ORDER BY: legacy-драйвер {SQL Server} + pyodbc ломается на описании колонок + rows = cur.execute("SELECT deviceId, Code FROM tblDevices").fetchall() + if query: + q = query.lower() + rows = [r for r in rows if q in (r[1] or "").lower()] + rows.sort(key=lambda r: r[1] or "") + + out: list[dict[str, Any]] = [] + for device_id, code in rows: + pr = _params(device_id) + try: + in_a = int(float(pr.get(P_IN, "") or 0)) + except ValueError: + in_a = None + if in_min is not None and (in_a is None or in_a < in_min): + continue + if in_max is not None and (in_a is None or in_a > in_max): + continue + b = _bounds(device_id) + if with_geometry_only and b is None: + continue + out.append({ + "device_id": device_id, + "code": pr.get(P_CODE, code), + "catalog_type": pr.get(P_CAT_TYPE, ""), + "in_a": in_a, + "icu_ka": pr.get(P_ICU, ""), + "poles": pr.get(P_POLES, ""), + "design": pr.get(P_DESIGN, ""), + "vendor": pr.get(P_VENDOR, ""), + "bounds_mm": b, + "series": _series(pr.get(P_CODE, code)), + }) + if len(out) >= limit: + break + return out + + +@mcp.tool() +def dkc_device(device_id: int) -> dict[str, Any]: + """Полные параметры позиции каталога по deviceId (deviceId = elementDevice в XML RamCube).""" + pr = _params(device_id) + cur = _db().cursor() + row = cur.execute("SELECT Code FROM tblDevices WHERE deviceId = ?", device_id).fetchone() + if not row: + raise ValueError("deviceId %s не найден в каталоге" % device_id) + return { + "device_id": device_id, + "code": pr.get(P_CODE, row[0]), + "params": {str(k): v for k, v in sorted(pr.items())}, + "bounds_mm": _bounds(device_id), + "series": _series(pr.get(P_CODE, row[0])), + "model_3d": MODELS.get(_series(pr.get(P_CODE, row[0])) or "", None), + } + + +# ---------------------------------------------------------------- XML проекта +def _load(path: str) -> ET.ElementTree: + if not os.path.exists(path): + raise FileNotFoundError(path) + return ET.parse(path) + + +def _cab_widths(root: ET.Element) -> list[dict[str, Any]]: + ass = {} + for e in root.find("elementsAssembly") or []: + a = e.attrib + try: + w = float(a.get("elementBoundMaxX", 0)) - float(a.get("elementBoundMinX", 0)) + except (TypeError, ValueError): + w = 0.0 + ass[a["elementId"]] = (a.get("elementDevice"), w) + inst = {i.attrib["elementInstanceId"]: i.attrib for i in root.find("elementInstancesAssembly") or []} + rows = [] + for c in root.find("cabinets") or []: + sig = c.attrib.get("cabinetSignature", "?") + i = inst.get(c.attrib.get("cabinetElementInstance"), {}) + dev, w = ass.get(i.get("elementInstanceElement"), ("?", 0.0)) + kind = ("cable" if "Кабельн" in sig else "bus" if "Шинн" in sig else + "input" if "Вводн" in sig else "section" if "Секцион" in sig else + "distribution" if "Распредел" in sig else "other") + rows.append({"signature": sig, "device": dev, "width_mm": round(w, 1), "kind": kind}) + return rows + + +@mcp.tool() +def ramcube_project_read(xml_path: str) -> dict[str, Any]: + """Сводка проекта RamCube из XML: параметры, аппараты схемы, панели, габарит.""" + root = _load(xml_path).getroot() + a = root.attrib + + dict_props = {r.attrib["propertyProperty"]: r.attrib["propertyName"] + for r in root.find("properties") if "propertyProperty" in r.attrib} + vals: dict[str, dict[str, str]] = {} + for r in root.find("properties"): + x = r.attrib + if "propertyValueInstance" in x: + vals.setdefault(x["propertyValueInstance"], {})[x["propertyValueProperty"]] = x["propertyValueContent"] + + elems = {e.attrib["elementId"]: e.attrib for e in root.find("elements")} + apparatus = [] + for ins in root.find("elementInstances"): + i = ins.attrib + e = elems.get(i["elementInstanceElement"], {}) + pr = vals.get(i["elementInstanceInstance"], {}) + apparatus.append({ + "instance": i["elementInstanceId"], + "element": i["elementInstanceElement"], + "device": e.get("elementDevice"), + "code": pr.get("9"), + "in_a": pr.get("2"), + "x": i.get("elementInstancePositionX"), + "signature": i.get("elementInstanceSignature", ""), + }) + + cabs = _cab_widths(root) + total = round(sum(c["width_mm"] for c in cabs), 1) + useful = round(sum(c["width_mm"] for c in cabs if c["kind"] in ("input", "distribution", "section")), 1) + + return { + "file": xml_path, + "project": {k: a.get(k) for k in ( + "projectName", "projectIP", "projectAmperageNominal", "projectAmperageShortCircuit", + "projectSectioning", "projectCableEntrance", "projectDimensionsHeight", "projectExistsAVR")}, + "counts": {t: len(list(root.find(t))) for t in ( + "elements", "elementsAssembly", "elementInstances", "elementInstancesAssembly", + "cabinets", "schemes", "buses", "wires")}, + "apparatus": apparatus, + "cabinets": cabs, + "length_mm": {"total": total, "useful": useful, + "service": round(total - useful, 1)}, + "notes": {"cable_entrance": "1 = сверху, 0 = снизу"}, + } + + +@mcp.tool() +def ramcube_apparatus_replace(xml_in: str, xml_out: str, + replacements: list[dict[str, Any]]) -> dict[str, Any]: + """Замена аппаратов в схеме XML. + + replacements — список: {"element_id": "-174426", "new_device_id": 11919} + (или вместо element_id — "element_device": "11765", тогда меняются все элементы с этим deviceId). + Правятся оба набора свойств (эталон элемента и каждый экземпляр) — иначе RamCube + на загрузке выдаёт «код экземпляра не соответствует элементу». + """ + tree = _load(xml_in) + root = tree.getroot() + + elems = {e.attrib["elementId"]: e for e in root.find("elements")} + targets: dict[str, dict[str, str]] = {} # elementId -> props нового аппарата + report = [] + + for rep in replacements: + new_id = int(rep["new_device_id"]) + pr = _params(new_id) + code = pr.get(P_CODE, "") + series = _series(code) + props = { + "9": code, + "2": pr.get(P_IN, ""), + "3": pr.get(P_IN_R, ""), + "8": pr.get(P_ICU, ""), + "12": pr.get(P_CAT_TYPE, ""), + "43": pr.get(P_DESIGN, ""), + } + model = rep.get("model") or MODELS.get(series or "") + matched = [] + for eid, el in elems.items(): + if rep.get("element_id"): + hit = eid == str(rep["element_id"]) + else: + hit = el.attrib.get("elementDevice") == str(rep.get("element_device")) + if not hit: + continue + old = el.attrib.get("elementDevice") + el.attrib["elementDevice"] = str(new_id) + if model: + el.attrib["elementContentFileName"] = model + targets[eid] = props + matched.append({"element_id": eid, "old_device": old, "new_device": str(new_id), + "code": code, "series": series}) + report.extend(matched) + + if not report: + raise ValueError("ни один элемент не найден по заданным criteria") + + # эталон элемента + наборы экземпляров + inst_props: dict[str, dict[str, str]] = {} + for eid, props in targets.items(): + ei = elems[eid].attrib.get("elementInstance") + if ei: + inst_props[ei] = props + for ins in root.find("elementInstances"): + eid = ins.attrib.get("elementInstanceElement") + if eid in targets: + inst_props[ins.attrib["elementInstanceInstance"]] = targets[eid] + + changed = 0 + for rec in root.find("properties"): + x = rec.attrib + inst = x.get("propertyValueInstance") + if inst in inst_props and x.get("propertyValueProperty") in inst_props[inst]: + x["propertyValueContent"] = inst_props[inst][x["propertyValueProperty"]] + changed += 1 + + tree.write(xml_out, encoding="utf-8", xml_declaration=True) + + # валидация: перечитать и сверить коды + check = ET.parse(xml_out).getroot() + cvals: dict[str, dict[str, str]] = {} + for r in check.find("properties"): + x = r.attrib + if "propertyValueInstance" in x: + cvals.setdefault(x["propertyValueInstance"], {})[x["propertyValueProperty"]] = x["propertyValueContent"] + verified = [] + for ins in check.find("elementInstances"): + i = ins.attrib + if i.get("elementInstanceElement") in targets: + pr = cvals.get(i["elementInstanceInstance"], {}) + verified.append({"instance": i["elementInstanceId"], "code": pr.get("9"), "in_a": pr.get("2"), + "catalog_type": pr.get("12"), "design": pr.get("43")}) + + return {"out": xml_out, "elements": report, "property_sets": len(inst_props), + "values_changed": changed, "verified_instances": verified} + + +@mcp.tool() +def ramcube_set_project(xml_path: str, name: str | None = None, ip: str | None = None, + sectioning: Literal["3b", "4b"] | None = None, + cable_entrance: Literal["сверху", "снизу"] | None = None, + in_nominal: int | None = None, ikz_ka: float | None = None, + exists_avr: bool | None = None) -> dict[str, Any]: + """Параметры проекта RamCube. cable_entrance: 'сверху' = 1, 'снизу' = 0.""" + tree = _load(xml_path) + a = tree.getroot().attrib + if name: + a["projectName"] = name + if ip: + a["projectIP"] = str(ip) + if sectioning: + a["projectSectioning"] = sectioning + if cable_entrance: + a["projectCableEntrance"] = "1" if cable_entrance == "сверху" else "0" + if in_nominal: + a["projectAmperageNominal"] = str(in_nominal) + if ikz_ka is not None: + a["projectAmperageShortCircuit"] = str(ikz_ka) + if exists_avr is not None: + a["projectExistsAVR"] = "true" if exists_avr else "false" + tree.write(xml_path, encoding="utf-8", xml_declaration=True) + return {k: tree.getroot().attrib.get(k) for k in ( + "projectName", "projectIP", "projectSectioning", "projectCableEntrance", + "projectAmperageNominal", "projectAmperageShortCircuit", "projectExistsAVR")} + + +if __name__ == "__main__": + mcp.run() diff --git a/test_client.py b/test_client.py new file mode 100644 index 0000000..983af6a --- /dev/null +++ b/test_client.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +"""Тест MCP-сервера: инструменты + запись на копии проекта.""" +import asyncio +import json +import shutil + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +PY = r"C:\Users\Admin\mcp\dkc-ramcube\.venv\Scripts\python.exe" +SRV = r"C:\Users\Admin\mcp\dkc-ramcube\server.py" +SRC = r"C:\Users\Admin\Downloads\GRSH1.1_RamCube\vrsh1.xml" +TMP = r"C:\Users\Admin\mcp\dkc-ramcube\_test_copy.xml" + + +def show(res, limit=700): + txt = "".join(getattr(c, "text", "") or "" for c in res.content) + try: + data = json.loads(txt) + print(json.dumps(data, ensure_ascii=False, indent=1)[:limit]) + return data + except Exception: + print(txt[:limit]) + return None + + +async def main(): + shutil.copy(SRC, TMP) + params = StdioServerParameters(command=PY, args=[SRV]) + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as s: + await s.initialize() + print("=== инструменты:", [t.name for t in (await s.list_tools()).tools]) + + print("\n=== dkc_search('MNX250', in_min=200)") + show(await s.call_tool("dkc_search", {"query": "MNX250", "in_min": 200, "limit": 2})) + + print("\n=== ramcube_apparatus_replace: элементы -174426 -> NW20") + show(await s.call_tool("ramcube_apparatus_replace", { + "xml_in": TMP, "xml_out": TMP, + "replacements": [{"element_id": "-174426", "new_device_id": 11820}]})) + + print("\n=== ramcube_set_project (имя, кабели сверху)") + show(await s.call_tool("ramcube_set_project", { + "xml_path": TMP, "name": "ВРЩ1_тест", "cable_entrance": "сверху"})) + + print("\n=== ramcube_project_read(копии)") + show(await s.call_tool("ramcube_project_read", {"xml_path": TMP}), 450) + + +if __name__ == "__main__": + asyncio.run(main())