twincat-mcp
# twincat-mcp
One MCP server, one Python process, full reach into a Beckhoff TwinCAT 3 setup:
| Layer | What the agent can do | Backend |
|---|---|---|
| **Runtime (target IPC)** | PLC/system state, symbol browse, typed read/write (structs, arrays, TIME/DATE decoded), raw index-group access, polling trace, wait-until, run/stop/reset, RUN↔CONFIG mode, EtherCAT master state, routes | pyads (ADS) |
| **IPC file system / OS** | read/write/list/delete files on the target over ADS (TcUnit results, logs, boot dir), start a process, reboot | ADS System Service (port 10000) |
| **Engineering (XAE)** | attach to the running XAE shell, open/build with file:line errors, PLC login/start/stop, set target, activate configuration, restart TwinCAT, one-shot deploy, System Manager tree read/write (XML), create/edit POUs in place, PLCopen import/export, boot project | TE1000 Automation Interface over COM (pywin32) |
| **Real-time recorder** | continuous capture of online variables while the program runs — PLC-timestamped ADS notifications (cycle or on-change) or polling, hours long, file-backed; live snapshot/stats while recording; scope-style start/stop triggers with pre-trigger; CSV export; pulse/dwell and edge-to-edge latency analysis | pyads notifications |
| **Test bench** | scripted scenarios (writes + expectations + parallel recording), assertions with wait, settle checks, stimulus→response latency, RPC method calls, task timing/load, snapshots for regression | ADS |
| **Project files (offline)** | index .TcPOU/.TcDUT/.TcGVL/.TcIO, code with line numbers, **ADS symbol → file:line resolution**, regex search, references, call graph, FB interface summary, lint, write-back with LineIds hygiene, TcUnit suite scaffold | XML + regex, no XAE needed |
| **TcUnit** | build → boot → activate → restart → fetch xUnit XML from the target → pass/fail summary | all three |
| **Experiments** | spec (factors × levels, runs, preconditions, stimulus, done condition, measures, metrics, criteria, allowed writes) → background runner with snapshot + capture profile per run, factor restore, abort → metrics with 95 % CIs (t / χ²), Cpk → pass / fail / **inconclusive** with the n that would settle it → Markdown + HTML report with identity, charts, audit trail and a separately-labelled interpretation; compare two experiments with error bars | recorder + bench + SQLite-free JSONL |
| **Reference (Infosys)** | TwinCAT 3 documentation lookup that cannot be incomplete: books and pages from infosys.beckhoff.com's own sitemap, per-library local index (FTS5), symbol search, **FB/struct/enum declarations as data** with the page's descriptions, code-example search, any page as Markdown; web fallback verified against the sitemap | urllib + SQLite, no index to ship |
Runs **on the engineering PC** (Windows, TwinCAT XAE installed) and talks to the IPC through the TwinCAT router. No C# helper, no daemon, no PowerShell, no vector DB.
---
## Why another one
Existing servers each cover a slice; this one was built after reading all of them:
| Server | Gap this project closes |
|---|---|
| [malcolm-mill/beckhoff_mcp](https://github.com/malcolm-mill/beckhoff_mcp) (pyads, read-only) | writes, structs/arrays, TIME/DATE decoding, watch/trace, XAE, files on target |
| [Edge-JB/TwinCAT-XAE-MCP](https://github.com/Edge-JB/TwinCAT-XAE-MCP) (Node + .NET daemon, XAE only) | needs an MSBuild step and the 64-bit shell; no runtime reads, no TcUnit. Confirmation-token and safety-project ideas kept. |
| [idomp/twincat-mcp-public](https://github.com/idomp/twincat-mcp-public) (Python + C# `TcAutomation.exe`) | needs MSBuild + Windows PowerShell 5.1 to build the helper. Arm/confirm + persistent-shell + TcUnit flow kept, in pure Python. |
| [Auda29/twincat-mcp-mono](https://github.com/Auda29/twincat-mcp-mono) (TypeScript ADS) | three-gate write model kept (mode + runtime + allowlist); XAE/files added. |
| [agenticcontrolio/twincat-validator-mcp](https://github.com/agenticcontrolio/twincat-validator-mcp) | evaluated 2026-10 and not adopted: its LineIds model (one entry per block, Ids from 1), an invented OVERRIDE marker, "base FB must be nearby" and "undeclared target" for FB members inside methods all fire as errors on real XAE files, and its default autofix re-indents every tab-indented line. The ideas that hold (interface contract, `__NEW`/`__DELETE` pairing, unsigned-loop wrap, abstract instantiation) are `proj_lint` rules here, resolved against the project index so library bases produce *info*, not false errors. |
| [idomp/twincat-index-mcp](https://github.com/idomp/twincat-index-mcp) | semantic search needs Qdrant + embeddings. Here: deterministic symbol-to-source resolution instead. |
| Infosys documentation servers (`infosys_search` / `infosys_get_page` style, pre-built index) | the shipped index is whatever the author crawled: tested one has no Tc2_System at all (`ADSREAD` → nothing, `get_page` → "not found" for a live page), duplicate `?id=` URLs, BA2 functions ranked above `TON`. Here the page list comes from the site's sitemap and pages are fetched live, so an existing page can never be "not found"; TwinCAT 2 books are filtered out. |
Extra: human-in-the-loop via **MCP elicitation** — when the client supports it (Claude Code does) the destructive tools ask *you* in the client UI; the token flow is only the fallback.
---
## Requirements
* Windows 10/11 engineering PC with **TwinCAT XAE 3.1.4024+** (XAE Shell, 32-bit `TcXaeShell.DTE.15.0`, or 64-bit 4026 `TcXaeShell.DTE.17.0`, or VS 2019/2022/2026 with TwinCAT).
* Python **3.12 64-bit** (the one version the project targets and tests; 3.12 or newer is accepted, older ones are refused with a message). pywin32 and pyads are installed by `pip`.
* An ADS route from this PC to the target (XAE → Choose Target → Add Route). `ads_routes` lists what exists.
* Open the solution in XAE before using `xae_*` tools; the server attaches to the running instance (it can launch one, but attaching is faster and visible).
Linux/macOS: only `proj_*`, `tcunit_results(local_path/xml_text)` and the safety tools work. Everything else needs the router/COM.
## Install
Three ways. All need a 64-bit Python 3.12 on the engineering PC; nothing else is installed by hand — the
launcher (`scripts/launch.py`) creates a private venv on first start and reinstalls only when the shipped
sources change.
### A. Claude Code plugin (recommended)
The repo is its own plugin marketplace (`.claude-plugin/`). Inside Claude Code:
```
/plugin marketplace add Disectron/twincat-mcp-server :: GitHub; or a local clone: /plugin marketplace add C:\tools\twincat-mcp
/plugin install twincat-mcp@twincat-mcp
```
One step on Claude Code ≥ 2.1.275: `/plugin install twincat-mcp --marketplace Disectron/twincat-mcp-server`. The plugin brings
the MCP server (named `twincat`, tools appear as `mcp__plugin_twincat-mcp_twincat__<tool>`), two skills —
`twincat-mcp` (tool chains) and `twincat-docs` (offline Beckhoff documentation, below) — and the 5 prompts. `/plugin configure twincat-mcp@twincat-mcp` sets the Python executable (default
`python`), default AmsNetId, start/ceiling safety mode and an optional config TOML; everything project-specific
(solution path, allow/deny lists, HMI root) comes from `twincat_mcp.toml` in the folder you start `claude` in.
First start takes about a minute (venv + pip); if that first session reports the server as failed, the venv is
already there — start a new session. The venv lives in `~/.claude/plugins/data/twincat-mcp-twincat-mcp/venv`
and survives plugin updates.
### B. Claude Desktop bundle
`scripts\build-mcpb.cmd` produces `dist\twincat-mcp-<version>.mcpb` (or take the one from a release). Claude
Desktop → Settings → Extensions → drag the file in. The install dialog asks for the solution, AmsNetId and
safety modes. Windows only (the bundle runs `cmd /d /c server\launch.cmd`, which finds `py -3`, `python` or
`%LOCALAPPDATA%\Programs\Python`). Logs: `%APPDATA%\Claude\logs\mcp-server-twincat-mcp.log`.
### C. By hand (any MCP client, development)
```bat
git clone <this repo> C:\tools\twincat-mcp
cd C:\tools\twincat-mcp
scripts\install.cmd :: venv + pip install -e .[dev] + prints effective config
.venv\Scripts\python -m pytest :: 159 tests, all mocked, run anywhere
cd C:\Projects\DrumFilling
claude mcp add twincat -e TCMCP_SOLUTION=%CD%\DrumFilling.sln -e TCMCP_AMS_NET_ID=5.12.34.56.1.1 -e TCMCP_MODE=guarded -e TCMCP_MAX_MODE=full -- C:\tools\twincat-mcp\.venv\Scripts\python.exe -m twincat_mcp
```
or copy `.mcp.json.example` → `.mcp.json` in the solution folder. Cursor / Windsurf / Claude Desktop's
`claude_desktop_config.json` use the same JSON shape.
First prompt in any client: *"run tc_doctor"*. It reports router, local NetId, target state, registered
ProgIDs, running shells, project root and the safety mode. Fix what it flags before anything else.
## Configuration
Env vars override `twincat_mcp.toml` (`./`, `~/.twincat_mcp/config.toml`, or `TCMCP_CONFIG`). See `twincat_mcp.toml.example`.
| Env | Meaning | Default |
|---|---|---|
| `TCMCP_MODE` | start mode `readonly` / `guarded` / `full` | `guarded` |
| `TCMCP_MAX_MODE` | ceiling for `tc_set_mode` | = mode |
| `TCMCP_CONFIRM` | `0` → destructive ops in **full** mode need no confirmation | `1` |
| `TCMCP_WRITE_ALLOW` / `TCMCP_WRITE_DENY` | `;`-separated fnmatch patterns (deny always wins) | empty |
| `TCMCP_AMS_NET_ID`, `TCMCP_AMS_PORT` | target | local, `851` |
| `TCMCP_SOLUTION` | `.sln` to attach to; also sets project root | — |
| `TCMCP_PROJECT_ROOT` | folder for `proj_*` | solution folder |
| `TCMCP_PROGIDS` | `;`-separated DTE ProgIDs to try, in order | 17.0, 15.0, VS 18/17/16 |
| `TCMCP_XAE_ATTACH_ONLY` | `0` → `xae_attach` may launch a shell | `1` |
| `TCMCP_TCUNIT_RESULT` | result path on the target | `C:\TwinCAT\3.1\Boot\TcUnit_xUnit_testresults.xml` |
| `TCMCP_AUDIT_LOG`, `TCMCP_AUDIT_READS` | JSONL audit file; also log reads | `~/.twincat_mcp/audit.jsonl`, `0` |
| `TCMCP_DOCS`, `TCMCP_DOCS_SOURCE`, `TCMCP_DOCS_MSHC`, `TCMCP_DOCS_BOOKS`, `TCMCP_DOCS_EXCLUDE`, `TCMCP_DOCS_WEB_FALLBACK`, `TCMCP_DOCS_CACHE` | Infosys lookup on/off; `local` (Help Viewer package) or `web`; the .mshc; `;`-separated books indexed first; exclude regexes; web fallback (web only); cache dir | `1`, `local`, newest found, 8 core PLC libraries, —, `1`, `~/.twincat_mcp/infosys` |
## Safety model
| op class | examples | readonly | guarded | full |
|---|---|---|---|---|
| read | any `ads_read*`, `proj_*`, `xae_status`, `ipc_read_file` | ok | ok | ok |
| edit | `proj_set_code`, `xae_pou_set`, `xae_tree_consume_xml`, `xae_build`, `xae_plcopen_import/export`, `rec_start`, `snap_save`, `tcunit_scaffold` | — | ok | ok |
| write | `ads_write`, `ads_write_raw` (PLC ports only, overlapping symbols checked), `test_scenario`/`test_response` writes, `ads_call_method` | — | allowlist ∧ ¬denylist | ¬denylist |
| control | `ads_plc_state run/stop`, `ads_connect` to a new NetId, `xae_plc login/start/stop`, `xae_target set`, `xae_command`, `ipc_write_file`, `ads_add_route_to_plc` | — | confirm | ok |
| destructive | `xae_activate_configuration`, `xae_restart_twincat`, `xae_deploy`, `ads_system_mode`, `ads_plc_state reset`, `xae_plc download/reset_*`, `xae_command` with download/reset/activate/…, `xae_tree_delete`, `ipc_delete_file`, `ipc_start_process`, `ipc_reboot`, `tcunit_run` | — | confirm | confirm* |
\* unless `TCMCP_CONFIRM=0`.
"Confirm" = elicitation prompt in the client when supported (on MCP 2026-07-28 the question is returned to the client as an `input_required` result and the client calls the tool again with the answer; on earlier revisions it is an elicitation request during the call; `tc_doctor` → `elicitation` says which applies and whether the client declared the capability), otherwise `tc_request_confirmation(action, target)` → single-use token, 180 s TTL, bound to that action, target **and the machine the server was connected to when it was issued** (re-targeting with `ads_connect` invalidates it). `xae_deploy` / `tcunit_run` refuse when the XAE target and the ADS target differ. The agent can escalate its own mode only up to `TCMCP_MAX_MODE`, which it cannot change. Every mutating call lands in the audit log (`tc_audit_tail`).
**"Absolute control" setting:** `TCMCP_MODE=full TCMCP_MAX_MODE=full TCMCP_CONFIRM=0`. In that configuration a single mis-aimed tool call can stop the PLC, activate a half-built configuration or reboot the IPC. The server will do exactly what it is told; the only remaining gate is the MCP client's own tool-permission prompt. Keep a denylist for E-stop/safety symbols regardless.
## Tools (151) + 5 prompts
**Workflows (one call = a whole chain)** `wf_debug_symbol` · `wf_fb_profile` · `wf_release_check` · `wf_project_overview`
**Prompts (recipes the agent follows)** `debug_symbol` · `fb_validation` · `deploy_safely` · `performance_audit` · `onboard_project`
**Meta** `tc_doctor` · `tc_safety_status` · `tc_set_mode` · `tc_request_confirmation` · `tc_audit_tail`
**Runtime (ADS)** `ads_discover` · `ads_connect` · `ads_status[port]` · `ads_list_symbols[flag]` · `ads_symbol_info` · `ads_read` · `ads_read_many` · `ads_leaves` · `ads_dump` · `ads_types` · `ads_io_read` · `ads_write` · `ads_write_many` · `ads_read_raw` · `ads_write_raw` · `ads_watch` · `ads_wait_until` · `ads_plc_state` · `ads_system_mode` · `ads_ethercat` · `ads_routes` · `ads_add_route_to_plc` · `ads_app_info` · `ads_task_stats` · `ads_ping` · `ads_call_method` · `ads_export` (C#/TS/JSON from the datatype table)
**Recorder** `rec_start` · `rec_capture` · `rec_list` · `rec_status` · `rec_snapshot` · `rec_stats` · `rec_read` · `rec_stop` · `rec_verify` · `rec_export` · `rec_delete` · `rec_pulses` · `rec_latency` · `rec_compare`
**Test bench** `test_expect` · `test_stable` · `test_response` · `test_scenario` · `snap_save` · `snap_compare` · `snap_apply` · `snap_list`
**Target OS (System Service)** `ipc_read_file` · `ipc_list_dir` · `ipc_write_file` · `ipc_delete_file` · `ipc_start_process` · `ipc_reboot` · `ipc_eventlog` (Windows targets)
**XAE** `xae_attach` · `xae_status` · `xae_open_solution` · `xae_build` · `xae_diagnostics` · `xae_list_commands` · `xae_command` · `xae_plc` · `xae_target` · `xae_activate_configuration` · `xae_restart_twincat` · `xae_deploy` · `xae_tree_get` · `xae_tree_consume_xml` · `xae_tree_set_values` · `xae_tree_create_child` · `xae_tree_delete` · `xae_plc_projects` · `xae_pou_get` · `xae_pou_set` · `xae_pou_create` · `xae_plcopen_import` · `xae_plcopen_export` · `xae_boot_project` · `xae_dialogs` · `xae_dialog_resolve` · `xae_io_disable` · `xae_links`
**Project files** `proj_scan` · `proj_list` · `proj_get_code` · `proj_resolve_symbol` · `proj_search` · `proj_references` · `proj_read_file` · `proj_set_code` · `proj_interface` · `proj_lint` · `proj_call_graph` · `proj_variables` · `proj_vs_runtime` · `proj_changed` · `proj_validate_xml`
**HMI (TE2000 / TcVIS)** `hmi_scan` · `hmi_bindings` · `hmi_vs_runtime`
**TcUnit** `tcunit_results` · `tcunit_run[disable_io]` · `tcunit_scaffold`
**Experiments** `exp_template` · `exp_validate` · `exp_save` · `exp_list` · `exp_get` · `exp_runs` · `exp_run[dry_run]` · `exp_status` · `exp_abort` · `exp_evaluate` · `exp_report` · `exp_compare`
**Knowledge base** `kb_set_location` · `kb_status` · `kb_rebuild` · `kb_add_project` · `kb_remove_project` · `kb_reindex` · `kb_search` · `hw_lookup` · `hw_search` · `hw_object` · `hw_diag` · `hw_module` · `proj_libraries` — the database lives in a folder you name; only project folders you add are indexed; the `hw_*` tools read the ESI index (every ESI file in the knowledge folder's `manuals\` and TwinCAT's ESI folders)
**Offline documentation — skill `twincat-docs`** (no MCP tools; a read-only script the skill runs). The knowledge
folder has one fixed layout (0.8.11; the rules are `docs/KNOWLEDGE_BASE.md`):
| in the knowledge folder | content | built by |
|---|---|---|
| `index\infosys-1033.db` | the TwinCAT 3 books of the Help Viewer package — the one Infosys index, read by the `infosys_*` tools and the skill | the plugin, the first time by itself; later `tools\infosys_local.py index` or `kb_rebuild(index='infosys')` |
| `index\manuals.db` | the PDF manuals in `manuals\` — page text, chapters, product codes | the plugin, the first time by itself; later `tools\manuals_db.py update` or `kb_rebuild(index='manuals')` (Python 3.12+ with pypdf or PyMuPDF) |
| `index\esi.db` | every EtherCAT device description on the PC: the ESI files you put in `manuals\` (e.g. Beckhoff's whole ESI folder) and TwinCAT's `Config\Io\EtherCAT` — devices, PDOs, PDO assignments, CoE dictionaries with defaults, diagnosis messages, modules | the plugin, the first time by itself; later `kb_rebuild(index='esi')` (the builder `esi_db.py`, minutes) |
| `index\knowledge.db` | projects for `kb_search` | the `kb_*` tools |
| `manuals\`, `sop\` | your PDFs and SOPs (Markdown) | you — the plugin never writes there |
| `tools\` | builder stubs that run the plugin's builders | the plugin (only files it shipped are replaced) |
`skills/twincat-docs/kb_docs.py` answers `docs_search`, `docs_declaration`, `docs_page`, `docs_books`,
`manual_search`, `manual_product`, `manual_toc`, `manual_page`, `manual_docs`, `hw_lookup`, `hw_search`, `hw_object`,
`hw_diag`, `hw_module`, `status` as one JSON object each (standard library, opens the indexes read-only). A name that exists in several libraries (`MC_MoveAbsolute` in
Tc2_MC2 and Tc3_DriveMotionControl) is never resolved silently: the answer is `ambiguous` with the candidates.
`kb_status` → `docs` shows both indexes, the PDFs not indexed yet and the exact command for this folder; `hw_lookup`
lists the manuals that mention a product code. Every index is `complete`, `rebuild_needed` (another format) or
`outdated` (the source changed) — the last two are used as they are until you approve the rebuild — or `missing`,
plus a build running, interrupted or waiting to replace it; the server reports it at every connection, in
`tc_doctor` (`offline_docs`), in `kb_status` and in each answer's `source`. Links are stored as page addresses:
`docs_page` and `docs_declaration` return them as `links`, which `docs_page` opens.
**Capture profiles** `rec_scan` · `rec_profile_save` · `rec_profile_list` · `rec_profile_get` · `rec_profile_delete` · `rec_profile_start` · `rec_profile_stop`
**Infosys reference (TwinCAT 3)** `infosys_books` · `infosys_index` · `infosys_search_symbols` · `infosys_declaration` · `infosys_search` · `infosys_search_code` · `infosys_get_page`
**Meta** also `tc_session_report` (markdown handover of everything this session changed/produced)
Resources: `twincat://config`, `twincat://safety`, `twincat://audit/recent`. Full one-line-per-tool reference with safety classes: **`TOOLS.md`** (generated from the server).
Claude Code loads MCP tool schemas lazily when a server exposes many tools, so the count costs little context until a tool is used. Performance: quick reads and long operations (watch, scenarios, dumps) run on separate ADS workers, so a 60 s watch never stalls an `ads_read`; recorder queries touch memory/files only; the datatype table and symbol table are cached (TTL 300 s, `refresh=true` to force).
## Connecting Claude Code
Claude Code starts the server itself over stdio — no port, nothing on the network, runs as your Windows user next to the TwinCAT router and the open XAE shell. Plugin install (section A above) is the short path; the manual `claude mcp add` route, scopes, permissions and troubleshooting are in **`integrations/claude-code/README.md`**.
Tool results reach Claude Code as the SDK's indented JSON text plus structured content; Claude Code shows them as collapsible tool-result blocks and the model summarises. For a human-readable handover use `tc_session_report` (markdown).
Also in the repo: `skills/twincat-mcp/SKILL.md` and `skills/twincat-docs/` (loaded by the plugin; or drop into `.claude/skills/`), `integrations/claude-code/CLAUDE.md.snippet` (conventions for every session), `integrations/claude-code/.mcp.json` template, `scripts\register-claude-code.cmd`.
## What 0.5.0 closes from the gap list
| gap | tool | status |
|---|---|---|
| modal dialogs stall COM | `xae_dialogs`, `xae_dialog_resolve`, watchdog with `xae.dialog_rules` (never auto-answers activate/run/download/login/delete/safety prompts); scoped to the XAE process — with no known shell pid nothing is listed or clicked | window enumeration verified on Windows CI (it found a real system dialog); button clicks untested on hardware |
| TwinSAFE guard | every XAE edit refuses `TISC`/`^Safety` paths unless `TCMCP_ALLOW_SAFETY=1` | done |
| XML structure + LineIds | `proj_validate_xml` (GUID format/uniqueness, property VAR blocks, LineIds counts, EOF); `fix=true`, `regenerate_lineids`; `proj_set_code regenerate_lineids=true` | done |
| HMI files | `hmi_scan` / `hmi_bindings` / `hmi_vs_runtime` for TE2000 `%s%PLC1.X%/s%` bindings and TcVIS — broken bindings vs the live PLC and vs source | done |
| TcUnit without hardware | `xae_io_disable`, `tcunit_run disable_io=true` | ITcSmTreeItem.Disabled, untested |
| PLC discovery | `ads_discover` (UDP 48899 broadcast) | protocol per XAE broadcast search, untested |
| variable links | `xae_links` — link targets per AT %I*/%Q* variable, unlinked variables and array elements | instance XML (DataAreas) + ProduceMappingInfo; checked against a 3.1.4026 project's XML and mapping table |
| event log | `ipc_eventlog` via wevtutil on Windows targets; CE7/TC-BSD: use the XAE error list | partial |
| MES glue | `ads_export csharp` — explicit-offset structs/enums from the datatype table, `csharp_symbols` constants, TypeScript, JSON | done |
| recipes | `snap_apply` — write a snapshot back, gated per symbol, dry-run first | done |
| handover | `tc_session_report` | done |
Still deliberately absent: Scope/TE13xx, NC, non-EtherCAT fieldbuses, library binary introspection, semantic search, ESI-based box creation.
## Reading every variable
The symbol upload alone does not enumerate elements of `ARRAY OF STRUCT`, nested arrays or deep FB internals. The server also uploads the **datatype table** (`ADSIGRP_SYM_DT_UPLOAD`) and uses it everywhere:
```
> ads_read MAIN.fbFill → the whole FB instance as one nested tree (one ADS read): structs,
arrays of structs, enums by name, TIME/DATE decoded
> ads_leaves MAIN → every scalar under the program with type and byte offset
> ads_leaves MAIN.arrSteps with_values=true
> ads_dump → every variable on the PLC → dump-<ts>.json (tree) + .csv (path,type,value)
> ads_dump pattern=GVL_* → only the GVLs
> ads_types ST_Recipe → members, offsets, sizes; ads_types pattern=E_* → enums
> proj_variables → what the source declares, flattened to the same instance paths
> proj_vs_runtime → declared-but-not-online / online-but-not-declared: proves the target
runs this source after a deploy
> rec_start prefix=MAIN → records every leaf under MAIN, array elements included
```
If the datatype upload fails on a target, every tool falls back to the member symbols the runtime publishes (what the other servers rely on) and says so in `decoder` / `datatype_table`.
## Real-time recording while the program runs
```
> rec_start prefix=MAIN.fbFill mode=change cycle_ms=1 name=fill-cycle
→ every scalar under MAIN.fbFill, PLC-timestamped on every change
> …run the machine, deploy, whatever…
> rec_status <id> → samples, missing_samples, completeness, drops, backlog, last values
> rec_snapshot <id> → the live tail
> rec_stats <id> → per symbol: count, min/max/mean, change count, BOOL edges,
state runs with dwell times
> rec_stop <id>
> rec_pulses <id> MAIN.fbFill.fbFine.bOpen → pulse count, width, period, duty
> rec_latency <id> MAIN.fbFill.fbValve.bOpen == TRUE MAIN.fbFill.rFlow > 0.5
> rec_read <id> t_from_ms=800 t_to_ms=1200 wide=true → what else was true around the event
> rec_export <id> csv_wide → spreadsheet (with the PLC's FILETIME per row)
> rec_verify <id> counter=MAIN.nCycleCount → PASS/FAIL: unmodified, no gaps, every cycle present
```
Modes: `cycle` = one sample per `cycle_ms` (set it to the task cycle for a complete trace), `change` = only when the value changes (cheap, exact edge times), `poll` = sum-read from the PC (any port, no notifications; timestamps are this PC's, labelled `time_source: pc`). Triggers: `start_when='MAIN.bStart == TRUE' pre_trigger_ms=500` keeps the half second before the trigger; `stop_when`, `duration_s` (PLC time), `post_trigger_s`, `max_events` end it. Recordings from earlier sessions stay readable (`rec_list`, `rec_read`, `rec_pulses`, `rec_export`).
**Accuracy (0.7).** Neighbouring variables share one ADS notification per contiguous memory block (an FB, a struct, an array): the runtime sends the whole block per sample and the leaves are decoded on the PC. On a 1 ms task, 300 counters as one block were lossless; as 300 separate notifications ~5 % complete — so select whole FBs/structs/arrays rather than hundreds of scattered globals. Every loss is counted: `missing_samples` / `completeness` per block from the PLC timestamps (cycle mode), `dropped` (backlog full), `dropped_on_stop`. A notification that cannot be registered fails the start (`allow_partial=true` records the rest and lists `unsubscribed_symbols`).
**Provenance.** The file (`~/.twincat_mcp/recordings/<id>.jsonl`) stores the raw bytes the runtime sent and the runtime's own sample timestamps: `_t0.filetime` is the PLC FILETIME of the first sample and `t_ms` has 0.1 µs resolution, so `filetime + t_ms·10⁴` is each sample's exact PLC time (`rec_export` adds it as `plc_filetime`). The footer carries a SHA-256 over every line, signed with a per-install key. `rec_verify <id> counter=MAIN.nCycleCount` proves (1) the file is unmodified, (2) no holes in the PLC timeline, (3) the per-cycle counter steps by exactly 1 — the ground truth for "no sample lost". The acceptance run does this with `TwinCAT_SystemInfoVarList._TaskInfo[1].CycleCount` at the task cycle.
**Native capture engine (0.8).** Cycle-mode recordings run in `tcmcp-capture.exe` (`twincat_mcp/bin/`), a C++ service built on Beckhoff's **AdsLib** (`AdsDevice` / `AdsNotification`) over Beckhoff's **TcAdsDll** — the TwinCAT router. The ADS callback copies each sample into a lock-free ring and returns; a writer thread merges the blocks by PLC timestamp and writes the same format-2 file, signed the same way. Python is no longer on the data path, so the 300-leaf/1 ms loss of the Python recorder does not apply (the simulator test writes 60 s of a 1200-byte block + counter at 40× real time without a loss). The footer adds `missing_head` (pre-trigger buffer not full), `missing_gaps`, `missing_tail` (PLC stopped before the window end), `overflow_samples` (ring full on this PC — reported, never silent), `requested_s`, `plc_span_s`, `window_start_ms`/`window_end_ms` and `verdict_hint`. One process per recording; nothing is subscribed unless every block is. `engine='python'` (or `[recorder] engine = "python"`) keeps the in-process recorder; without a matching exe, `auto` falls back to Python and says why (`engine_note`). `change`/`poll` modes and `allow_partial` stay on the Python recorder. `tc_doctor` → `recorder` shows which engine cycle recordings use. Build and protocol: `native/capture/README.md`.
## Choosing what to record: capture profiles
`rec_start` decides the symbol set per call. For batch-after-batch work the set should be decided
once, from a scan of what exists, and replayed. Three ways to choose, all ending in a named profile:
1. **Checklist.** `rec_scan prefix=MAIN.fbDispense checklist=batch` (or `source=project` offline) lists
every scalar leaf with type, size, a suggested mode (BOOL/INT/enum → `change`, REAL/TIME → `cycle`,
strings/arrays → `poll`) and the bytes/s it would cost, grouped by `program.instance`, and writes
`~/.twincat_mcp/profiles/batch.checklist.csv`. Put an `x` in `select`, optionally override `mode` /
`cycle_ms` per row, then `rec_profile_save name=batch from_checklist=batch`. `preselect=["*.bDone"]`
pre-marks patterns.
2. **In the source.** `{attribute 'tcmcp.record' := 'cycle'}` above a declaration selects it; on an
FB/STRUCT instance it selects the whole subtree, an inner `'off'` carves holes,
`{attribute 'tcmcp.cycle_ms' := '1'}` pins the rate. `rec_profile_save name=batch from_attributes=true`
collects them through the project index — the selection is versioned with the code and survives
renames the way nothing in a chat does. TwinCAT ignores unknown attributes.
3. **Explicit.** `rec_profile_save name=x symbols=[...] mode=auto` — `auto` reads the runtime type.
`rec_profile_start batch` starts one recording per (mode, cycle) group, named `batch/cycle@1`,
`batch/change@10`…, with the profile's `start_when` / `stop_when` / `pre_trigger_ms` (trigger symbols
are added to every group automatically); `rec_profile_stop batch` ends them together; `rec_stats`,
`rec_export`, `rec_compare` work per recording as before. Sources combine and later ones override
earlier ones, so a checklist can narrow what the attributes selected.
"By standard" there is no default selection: nothing is recorded that was not chosen, and the
suggested modes are heuristics shown next to the choice, not applied behind it.
## Testing an FB on the live machine
```
> test_scenario {
"name": "bulk-then-fine",
"record": {"prefix": "MAIN.fbFill", "mode": "change", "cycle_ms": 1},
"steps": [
{"at_ms": 0, "write": {"MAIN.stRecipe.rTargetKg": 25.0, "MAIN.bStart": true}},
{"wait_until": {"symbol": "MAIN.fbFill.eState", "op": "==", "value": 2, "timeout_ms": 3000}},
{"expect": [{"symbol": "MAIN.fbFill.fbValve.bOpen", "op": "==", "value": true}]},
{"wait_until": {"symbol": "MAIN.fbFill.bDone", "op": "==", "value": true, "timeout_ms": 60000}},
{"stable": {"symbol": "MAIN.rNetWeight", "duration_ms": 2000, "tolerance": 0.02}}
],
"final_expect": [{"symbol": "MAIN.rNetWeight", "op": "~=", "value": 25.0, "tolerance": 0.05},
{"symbol": "MAIN.fbFill.bError", "op": "==", "value": false}]
}
→ per-step pass/fail with times, written values restored, recording id for rec_* analysis
> test_response {"MAIN.fbFill.fbValve.bOpenCmd": true} "MAIN.fbFill.rFlow > 0.5"
→ PLC-timestamped latency from the write to the response
> ads_task_stats samples=100 → exec time min/max/mean, load %, cycle overruns while the FB runs
> snap_save before prefix=GVL_Params … change code … snap_compare before live tolerance=0.001
> proj_interface FB_Fill · proj_lint FB_Fill · proj_call_graph MAIN
> tcunit_scaffold FB_Fill → FB_Fill_Tests.TcPOU registered in the .plcproj; then tcunit_run
```
Writes in scenarios go through the same allow/deny lists as `ads_write`; nothing is written until every symbol in the script has been permitted. Writing to an `AT %I*` input does nothing useful (the I/O task overwrites it each cycle) — stimulate through unmapped inputs or a simulation GVL.
## Experiments: from runs to a report someone can sign
`rec_*` captures and `test_*` stimulates; `exp_*` turns repeated runs into conclusions. The spec is
YAML (or JSON) in `<project>/experiments/<name>/spec.yaml` — versioned with the code:
```yaml
name: preact-fill-validation
hypothesis: With lambda=0.95 the preact controller holds |bias| <= 2 g and sigma <= 3 g at 1000 g.
profile: preact-fill # capture profile started for every run
preconditions: [{symbol: MAIN.fbScale.bStable, op: "==", value: true, timeout_ms: 30000}]
factors:
- {name: target_g, symbol: GVL_Recipe.rTarget_g, levels: [500, 1000]}
- {name: lambda, symbol: MAIN.fbDispense.fbPreact.rLambda, levels: [0.95]}
runs_per_level: 5
run:
start: {symbol: MAIN.bStartFill, value: true, reset_value: false, reset_after_ms: 200}
done_when: {symbol: MAIN.fbDispense.ePhase, op: "==", value: IDLE, timeout_ms: 180000}
settle_ms: 2000
measures:
final_weight_g: {kind: last, symbol: MAIN.fbScale.rNet_g}
final_error_g: {kind: expr, expr: final_weight_g - target_g}
cut_latency_ms: {kind: latency, from: {symbol: MAIN.fbDispense.bCut, op: "==", value: true},
to: {symbol: MAIN.fbFlow.rFlow_gps, op: "<", value: 5.0}}
fine_pulses: {kind: pulses, symbol: MAIN.fbFine.bPulse}
metrics:
bias_g: {measure: final_error_g, stat: mean}
sigma_g: {measure: final_error_g, stat: std}
cpk: {measure: final_error_g, stat: cpk, tolerance: 5.0}
criteria:
- {metric: bias_g, op: abs_le, value: 2.0}
- {metric: sigma_g, op: le, value: 3.0}
- {metric: cpk, op: ge, value: 1.33}
writes_allowed: [GVL_Recipe.rTarget_g, MAIN.fbDispense.fbPreact.rLambda, MAIN.bStartFill]
```
`exp_template` returns a complete one for a dosing machine; `exp_validate` checks structure, resolves
every symbol on the connected PLC, and asks the server guard whether each write would be allowed;
`exp_run dry_run=true` prints the plan and writes nothing. `exp_run` then executes in the background —
per run: preconditions (`wait_until`) → factor values written → `snap_save` → capture profile started
→ stimulus → `done_when` → settle → stop → measures from the PLC-timestamped recording → append to
`runs.jsonl`. Factor symbols are read first and restored at the end or on `exp_abort`; two consecutive
failed runs stop the experiment. Resume continues where runs.jsonl stopped.
`exp_evaluate` is deterministic from `runs.jsonl`: per level the metrics with 95 % CIs (t for the
mean, χ² for σ, propagated for Cpk) and each criterion as **pass / fail / inconclusive** — inconclusive
when the limit lies inside the CI, with the run count that would (roughly) settle it. Five fills give
σ a ±40 % standard error; the report says so instead of rounding it away. `exp_report` writes
`report.md` + `report.html`: machine serial, device/TwinCAT version, project + git state, safety mode,
criteria table, metrics per level, charts (primary measure per run with limit lines; per-level mean/CI),
run table with recording ids, the spec as run, the audit log of every write during the experiment,
and an **Interpretation** section stored separately and labelled "not computed". `exp_compare a b`
puts two experiments side by side with "separated" where the CIs do not overlap.
Safety: every written symbol must be in `writes_allowed` *and* pass the guard — checked before anything
moves **and again before every single write**, so `tc_set_mode readonly` is a working kill switch; each
write is pinned to the machine the experiment started on (an `ads_connect` elsewhere makes the next
write fatal) and audited with symbol, value and run. Writes are scalars only (a struct write would bypass
the member deny-list). A run counts as started only when the machine leaves the done state within
`start_timeout_ms`; a rejected start is a failed run, not an instant pass. Factor originals are read and
checked for writability before the first run, restored and read back afterwards (or on abort, after any
`run.on_abort` safe-state writes); an abort interrupts a wait within a second; a timed-out write ends the
experiment because the PLC state is unknown. One experiment per target at a time; starting is
control-class (confirmation in guarded mode). The physical safeguards stay yours.
## Looking up Beckhoff documentation
The `infosys_*` tools exist because the documentation MCP servers available for TwinCAT ship a
pre-built index, and an index is only as complete as the crawl that made it. Against the one tested
here, `infosys_search_symbols("ADSREAD")` returned nothing, `infosys_get_page` reported a live
Tc2_System page as not found, and `TON` ranked BA2 helper functions first. This server does not ship
an index:
* **Source (0.8.3+): the Help Viewer package on this PC, by default.** TwinCAT XAE installs the complete
Information System as `BKINFOSYS3_VS_100_EN-US.<n>.mshc` under
`%ProgramData%\Microsoft\HelpLibrary2\Catalogs\<catalog>\ContentStore\EN-US\` (≈ 1 GB, ≈ 50k pages,
≈ 280 books). It is a zip of `<book>/1033/<pageid>.html` with the **same page ids as the website**, so
every page is stored under its real `https://infosys.beckhoff.com/content/1033/<book>/<id>.html` URL.
`docs.source = "local"` (default): no network call at all, no web fallback; the newest package is found
automatically (`[docs] mshc` to pin one). **One index** (0.8.11, rules in `docs/KNOWLEDGE_BASE.md`): every
TwinCAT 3 book of the package, built as a whole in its own process into `<knowledge folder>\index\infosys-1033.db`
(without a knowledge folder: `<cache_dir>\helpviewer-1033\index.sqlite`) — the same file the `infosys_*` tools
and the twincat-docs skill read. The first build starts by itself; after that a new package or a plugin update
that changes the stored format is reported (`tc_doctor`, `kb_status`, every answer) and the index is used as it
is until you approve `kb_rebuild(index='infosys')` or run `tools\infosys_local.py index`; the rebuild replaces
the index only when it is complete. Results carry the page's doc version (`Microsoft.Help.DisplayVersion`) and
`source.modified` — **the package is as old as the last Help Viewer update on this PC**; check that date
before trusting a page about a new build or library version. `infosys_get_page` reads any page in the package,
indexed or not, with its breadcrumb. `docs.source = "web"` brings back the behaviour below.
* **What exists** (web) comes from `infosys.beckhoff.com/sitemap.xml` (every English page URL with its
`lastmod`), cached a week. `infosys_books` groups it by book (`tcplclib_tc2_system`, `tf6100_tc3_opcua`,
`el3xxx`…) with page counts; TwinCAT 2 books are dropped (`tcplclib<name>` without underscore,
`twincat2/`). `Tc2_System`, `Tc2_Standard`, `Tc2_Utilities` are TwinCAT 3 libraries and stay.
* **What is indexed** is per book, on demand: `infosys_index tcplclib_tc2_system` fetches its pages
(≈300, parallel, ~30–60 s), converts each to Markdown with our own HTML walker, classifies the page
(FunctionBlock / Function / Struct / Enum / Alias / Interface / GVL / Library / Page), extracts the
declaration from the `VAR_INPUT`/`VAR_OUTPUT` code blocks and attaches the description tables, and
stores it in SQLite with FTS5. A later run refetches only pages whose `lastmod` changed. The first
`infosys_search_symbols` / `infosys_declaration` indexes the default libraries (`docs.books`) if
nothing is indexed yet.
* **`infosys_declaration ADSREAD`** → `{kind, inputs:[{name,type,default,description}], outputs,
in_out, members, enum_values, return_type, extends, implements, declaration, url}` — call it before
writing code against a library FB you have not used. `infosys_search_code "IDXGRP:=16#F005"` finds the
example that uses a constant; `infosys_search "rising edge"` is bm25-ranked full text with excerpts.
* **`infosys_get_page`** takes a URL, `book/pageid`, a bare page id, or a symbol name, fetches live
(cached by `lastmod`) and returns Markdown — for any page the site has, indexed or not. Host-locked
to infosys.beckhoff.com; the server is not a general web fetcher.
* **Web fallback**: when the local index has no hit, `infosys_search` asks DuckDuckGo for
`site:infosys.beckhoff.com <query>`, keeps only URLs that are in the sitemap and in TwinCAT 3 books,
fetches them for title/excerpt and caches them. `backend` in the result says which path answered.
`docs.web_fallback=false` turns it off.
Config (`[docs]` in the TOML / `TCMCP_DOCS*`): `source` (local | web), `mshc`, `enabled`, `cache_dir`; for the web
source `books` (indexed first), `exclude_books` (regexes), `tc3_only`, `max_workers` (≤16; be polite),
`max_index_pages`, `sitemap_ttl_days`, `web_fallback` — the local index holds every TwinCAT 3 book whatever they
say. The `infosys_*` tools are read-only class; `kb_rebuild` is control class and always asks for approval.
## A debugging session
```
> ads_status → PLC RUN, system RUN, TwinCAT 3.1.4024.x
> ads_read MAIN.fbFill → whole FB instance expanded: eState=3, tSettle=T#500ms, …
> ads_watch [MAIN.fbFill.eState, MAIN.fbFill.fbValve.bOpen] 5000ms 20ms
→ eState 3→4 at 812 ms, bOpen never true
> proj_resolve_symbol MAIN.fbFill.fbValve.bOpen
→ FB_Fill.TcPOU:14 (fbValve : FB_Valve), FB_Valve.TcPOU:5 (bOpen AT %Q*)
> proj_get_code FB_Fill → CASE eState OF … E_FillState.Bulk: fbValve.Open();
> proj_search "fbValve\.Open" → only one call site; the state that reaches Bulk is never entered
> xae_pou_set … / proj_set_code … → fix
> xae_build → 0 errors, 1 warning (file:line)
> xae_deploy → asks for confirmation, then save/build/activate/restart
> ads_wait_until MAIN.fbFill.bDone == TRUE 30000
```
## Verify-on-hardware list
Install, configuration and a 14-step zero-write smoke test: **`docs/INSTALL.md`**. Ordered first-day script that then exercises every layer, with expected outputs and what to paste back per failure: **`docs/HARDWARE_DAY.md`**.
The XAE and System-Service paths are written against Beckhoff's Infosys documentation and tested against fake COM/ADS objects; this repo was built on Linux, so the following have **not** been executed against a real XAE shell or IPC yet. Check them in this order on the engineering PC, with a non-production target:
1. `tc_doctor` → router ok, ProgIDs registered, running shell listed.
2. `xae_attach` → correct instance/solution; `xae_status` shows `target_net_id`.
3. `xae_build` → `error_source` should be `Windows.ErrorList` or `DTE2.ToolWindows`; if it says `OutputPane` the late-binding fallback is in use (still works, less precise).
4. `xae_pou_get` on a POU → `declaration`/`implementation` populated (late-bound `DeclarationText`; Beckhoff's own PowerShell examples use it this way).
5. `xae_plc login` → if "no working DTE command", run `xae_list_commands PLC` and put the right name into `PLC_LOGIN_COMMANDS` in `xae_backend.py`. On the 64-bit 4026 shell the `PLC.*` commands are reported missing by other projects; `ads_plc_state` covers run/stop/reset without the shell.
6. `ipc_read_file C:\TwinCAT\3.1\TwinCAT.xml` → file access (FOPEN/FREAD/FCLOSE).
7. `ipc_list_dir C:\TwinCAT\3.1\Boot\*` → FFILEFIND struct layout (4-byte handle + WIN32_FIND_DATAA) is the least certain piece here.
8. `ads_ethercat` → index groups 0x3/0x100 (master state) and 0x6 (slave count) on the master's ADS port.
9. `ipc_start_process` with `notepad.exe`, then `ipc_reboot` on a test box.
10. `tcunit_run` end-to-end.
11. `rec_start mode=change` on a few booleans, toggle them, `rec_stats` → edge timestamps plausible against the task cycle.
12. `ads_task_stats`, `ads_app_info` → `TwinCAT_SystemInfoVarList` members resolve by name (they are not in the uploaded symbol table).
13. `ads_call_method` on a method marked `{attribute 'TcRpcEnable'}` → check the `instance#method` handle name and the packed parameter layout against the result.
14. `ads_types ST_<something>` then `ads_read` of an `ARRAY OF STRUCT` variable → datatype-table parsing (`AdsDatatypeEntry` incl. the optional GUID/attribute/enum tails) against a real table; compare offsets with the TMC. On failure the server falls back to published members and reports `decoder: none` for arrays of structs.
15. `exp_run <name> max_runs=1` on a harmless spec → the stimulus write, the start acknowledgement, the done condition and the restore appear in `tc_audit_tail` as `exp_write` rows; then `exp_abort` during a run → factor restored, no recording left in `rec_list`.
16. `infosys_get_page ADSREAD` and `infosys_index tcplclib_tc2_system` → the HTML walker was written against the page *shape* reported through a Markdown converter, not the raw HTML (this repo was built on a host that cannot reach infosys.beckhoff.com). Check: title = symbol name, `kinds` in the index result mostly FunctionBlock/Function/Struct, `infosys_declaration ADSREAD` lists NETID…TMOUT with descriptions. If titles come back as "Beckhoff Information System" or kinds are all `Page`, the H1/`<pre>` selectors in `infosys.py::_MdWalker` need the real markup — one page's HTML pasted back is enough.
17. `scripts\launch.cmd` (used by the `.mcpb`) → Python discovery order `TCMCP_PYTHON` → `py -3` → `python` → `%LOCALAPPDATA%\Programs\Python\Python3*`; the Microsoft-Store `python.exe` stub fails the version probe and is skipped. Run it once by hand with `--no-run` and watch stderr.
Anything that fails: the tool error text contains the ADS/HRESULT code; paste it back and the fix is usually one constant.
## Changelog
**0.8.19** — PLC libraries through XAE, and the 0.8.18 retest (N194 – N196, N180) fixed at their cause. **Libraries (+8 tools, TE1000 ITcPlcLibraryManager):** `xae_library_list` gives the references as XAE holds them — libraries (name, version, distributor) and placeholders (default and effective resolution) — and flags a library referenced twice, a version that is not installed (ScanLibraries) and the references or requested versions that differ from the saved .plcproj of the open solution (an unsaved resolve or freeze cannot be seen there, and it says so); `xae_library_installed` lists every installed version; `xae_library_repositories` the repositories (or that this XAE does not give them). `xae_library_add`, `xae_library_remove`, `xae_placeholder_add`, `xae_placeholder_resolve` and `xae_placeholder_freeze` run once, carry XAE's messages and the references before / after, and never build; guarded mode asks, the confirmation names the PLC project and the reference (remove: its kind and version too); a name referenced twice (a placeholder and a library) is refused until `kind` says which, and if XAE removes the other one the result says so. **A TwinCAT restart during a python recording (N194):** a change-mode recording across RUN → CONFIG → RUN received nothing after the restart and verified PASS. The python engine now reads the runtime when no sample came for a second; a task cycle count that started again (or an outage of 3 s or more) is a restart: the same program gets its notifications registered again and the span is marked in the file (`_restart`, `resubscriptions`, `runtime_restarts`, label INCOMPLETE); another program ends the recording; one failed read is no restart. **PLCopen replace (N195):** a library reference missing from the listing right after the import no longer stops a replace; what seems removed — or a renamed copy that should be there — is listed again before anything is said, and a refusal names the copies it leaves. **Unknown arguments (N196):** every tool refuses an argument it does not have, before it runs (`tcunit_results(path=…)` read the boot result file instead). **Scope and a TwinCAT restart (N180):** after a recording with Scope, the Scope View log's state lines since StartRecord are walked — a restart that cut the capture, Scope recording again after it, or a router stop while Scope was not recording — and said in the result and the signed link (`restart_seen` / `restart`). Two review rounds per part (independent reviewers): every HIGH / MEDIUM fixed with a test red before; the LOW left are in docs/TRACKER.md.
**0.8.18** — the 0.8.17 retest (N189 – N193) and an in-depth review of the 0.8.16 – 0.8.17 Scope and recorder code, fixed at their cause; no addition. **One Scope, one owner (N189):** two recordings with Scope at once each got a part of the one Scope — the second stopped Scope at its end and saved the first one's capture, which was then saved and signed again for the first. Now a recording's Scope part owns Scope until it is finished, a call starting it from its check on (two starts sent together cannot both pass), a Scope recording of its own (`scope_record start` / `new`) until `scope_record stop`. Under another owner `rec_start(also_scope=true)` is refused before anything starts ("Scope belongs to recording …"; the 'ask' policy does not offer Scope, `scope_note` says why) and `scope_record` start / stop / save / disconnect / new are refused before they act; after `scope_record stop` the next recording with Scope asks to disconnect first, as after a recording. **Scope's span (N191):** a capture that begins later, ends earlier or runs past its recording by more than 1 s is said in the result and in the signed link (`scope_span`: covers x s of y s). **Audit (N190):** the disconnect question and the choice after a recording. **`xae_links` (N192, N193):** on TwinCAT 3.1.4026 the PLC instance has no tree children — it said "0 unlinked"; it now reads the variables from the instance's XML and the links from the mapping table (ProduceMappingInfo): link targets per variable, array element and member, the unlinked variables and elements (arrays of structs too), links whose variable is gone, and `not_checked` where gaps cannot be checked (a multi-dimensional array, a struct linked member by member); what cannot be read is said (`unlinked_count` null), never counted as 0; the instance path is accepted (every tool taking a PLC project path does). **Review fixes:** a `rec_start` cancelled during the start check no longer leaves `rec_stop` / `rec_delete` waiting forever with Scope recording; a StartRecord whose outcome is unknown (shell gone, E_POINTER after acting) stays with the recording and is stopped (it was "NOT started"); the start check reads only lines written after this StartRecord (another start's line confirmed a refused one); an answer to a Scope question is applied once, to the state it was asked about; 'new' says what ran when its StartRecord did not, and holds Scope between its Disconnect and its StartRecord; a dialog the shell shows after StartRecord is said again (`open_dialogs`); a delete between a finish's check and its link no longer leaves a signed link to a deleted recording; XAE messages read after a failed before-read are marked as possibly earlier calls'; the stale-data check compares one clock at a time (a start the Scope View log confirmed is this run's; without PLC time and confirmation it says it cannot tell). No index changes format. Not in this release: the offline documentation work (held); libraries are planned for 0.8.19 (docs/PLAN-0.8.19.md).
**0.8.17** — the Scope part of a recording has one owner (N182 at its root): each `rec_start(also_scope=true)` recording's Scope part is run by one task that takes, in order, the recording's end (it watches the recording), `rec_stop` / `rec_delete` asking to finish it and a stop asked again — in 0.8.16 four actors shared five tables and every review found another interleaving. The fourteenth review's findings are fixed with it: `rec_stop` / `rec_delete` with Scope carry `solution_mismatch` / `reattached` again; SaveSVD refused right after StopRecord ran says that StopRecord ran and that `scope_record(action='save')` saves what Scope holds (no "repeat it"); no "retry in a few seconds" after a busy text that withholds "repeat it"; a StopRecord refused before it ran (XAE busy, a dialog another call opened holding XAE, the XAE worker busy) is asked again by the next `rec_stop(id)` — also after the recording was deleted: `rec_status` shows the part (by its id or a unique prefix; a recording the recorder still holds is never said deleted), `rec_list` lists it under `scope_without_recording`; a StopRecord a dialog holds or that timed out is `stop_unknown` — never sent again, and Scope is not said to be stopped or not; a StartRecord a dialog holds is `start_unknown` and stays with the recording (0.8.16 said "NOT started" and Scope recorded with no owner) — the link then has `started: null` and `start_unknown`; the part exists before StartRecord runs (`state: starting`), so a `rec_stop` meanwhile finishes it; a `rec_start` cancelled before its StartRecord ran (still queued behind another XAE call) is not started — nothing of Scope is stopped or saved for it; the link's `started` is when StartRecord ran; `finished_by` comes from the recorder's own stop reason — a `rec_stop` just after the recording ended by itself says that end, a call still stopping the recording is never labelled as one that ended; a `rec_delete` cancelled during its stop or its file removal waits for the delete's outcome — nothing saved for the deleted recording, `finished_by` names rec_delete; `rec_delete` while the part is being saved (the recording had ended) lets that save complete, then deletes the ADS recording and lists the Scope files that stay (`scope_files_kept` — `rec_delete` removes the ADS recording and its exports, never Scope's files); a recording deleted while Scope was saved gets no signed link; `scope_record(save)` with the export tool and an existing CSV names its own way out (another path). **Scope after a recording (Ryan's decisions, 2026-10-21):** every Scope file the plugin saves goes to `<recordings>\Scope Recordings\` (0.8.16 and before: `<recordings>\scope\` — those files stay where they are; `[scope] export_dir` still overrides); `rec_delete` never deletes them. After a recording Scope holds its data (state Reply) until it is disconnected, so the user chooses what Scope does next: **close** (Disconnect — XAE's Scope view is cleared, Scope is ready for the next recording), **new** (Disconnect and start a new Scope recording at once — on its own, stopped and saved with `scope_record`) or **pause** (Scope keeps the data to look at in XAE). `rec_stop` asks with a prompt (`scope_next`); after a recording that ended by itself `rec_status` carries the question for the agent to ask (a read-only tool does not act); a client without prompts gets the question in the result. The next recording with Scope while Scope still holds data asks to disconnect it first — `rec_start(scope_disconnect=true / false)` is the answer. `scope_record(action='disconnect' | 'new')`: without the plugin's own saved recording in Scope, discarding what Scope holds needs the user's confirmation. **The 0.8.16 retest (S1 – S4):** N186 — Scope in `Reply` (still holding the previous recording) refuses StartRecord, and StartRecord's return value does not tell (1 when it records, 1 when it refused): the Scope View log does — a start it refuses is `NOT started` with Scope's state and how to bring it back to Config (Disconnect in XAE's Scope View), `scope_record(action='start')` too; a start the log confirms says so (`start_check`); and Scope data whose CSV says it ends before the recording began (`Starttime / Endtime of export`) is never linked to the recording nor sealed (`not_this_recording`) — the previous capture was saved and signed as the next recording's. N185 — TcScopeView.log begins with a binary header whose length field changes with every write: the log is fingerprinted from its first text line, so only the lines written since the call began are reported; `truncated` when more errors were found than shown. N187 — Python engine: the connection is closed after the drain's last program check (it read the symbol version on a closed connection: the tail was dropped, verdict FAIL with a lossless counter). N188 — a result without `xae_messages` says whether XAE's message buffer was read and empty or could not be read (`xae_messages_note`; an error: "XAE messages (…): none …" / "not read: …"). N181b — a call a dialog refused at its first COM call says nothing of it ran. No index changes format. Not in this release: the offline documentation work (held).
**0.8.16** — XAE messages and the 0.8.15 retests outside the documentation (S2 – S4), fixed at their cause, with independent reviews. **XAE messages:** what TwinCAT's System Manager logs during an action (`ITcSysManager2::GetLastErrorMessages`: activate, restart, build, ConsumeXml, CreateChild / DeleteChild, PLCopen import, boot project, online commands) comes with its result as `xae_messages` — TwinCAT's own text, also warnings on success; a failed action's error ends with "XAE messages (…)", and with what the call's earlier steps logged — also the steps of `xae_deploy`, `xae_activate_configuration`, `xae_restart_twincat`, `tcunit_run` before the one that failed; a call XAE rejects after an action of the same tool ran says which ran instead of "repeat it"; the messages are taken in the worker that ran the call, so a call queued behind never gets another's; a buffer that held something before the action is `earlier_messages`, not the action's. **Scope:** every numbered Scope View log (`TcScopeView_1.log` …, a second XAE shell) is read, `scope_view_log.logs_checked` / `logs_written` (N179); a recording that ends by itself (duration, stop_when, max_events …) finishes its Scope run — once, whichever of it and `rec_stop` comes first, `finished_by` says which, also when `rec_stop` is cancelled or times out before its Scope part (who ended the recording is the recorder's own stop reason); `rec_delete` stops Scope and saves nothing; a StopRecord XAE refuses is asked again by `rec_stop`; a StartRecord XAE refuses says the ADS recording runs and not to repeat `rec_start`; `rec_status` / `rec_list` show the Scope part (N182); SaveSVD refused after a TwinCAT restart is said as that, with the time from the Scope View log (without a reset line in the log: the state Scope is in; a log missing or unreadable is said so, no restart invented), and XAE's ExportCSV of what Scope still holds is `recovered` or the run's Scope data `lost` (N180). **XAE:** the interface replace reads TwinCAT 3.1.4026's export — `InterfaceAsPlainText` of the interface, its methods and properties (N175); a call a modal dialog refused did not run — a read-only tool says nothing was changed, any other that its earlier steps may have run (N181); the shell's own main frame is never a dialog (N176). **Confirmations:** the question says it expires after 10 minutes; a late answer is logged in server.log and audited `confirmation expired` (N178). **Multi-PLC:** `wf_debug_symbol` names the PLC project a name is in, as `proj_resolve_symbol` does (N141b). **Recorder:** `rec_status` / `rec_list` no longer fail while another recording is started or deleted during the reap of one that ended (and the Scope watch no longer takes such a failure for the recording's end); the Python engine's footer has `overflow_samples`, `requested_s`, `missing_head` / `missing_gaps` / `missing_tail` and `verdict_hint` as the native engine's. No index changes format. Not in this release (held): the error index and `err_lookup` (N125) and the documentation items of the 0.8.15 retest (N177, N183, N184).
**0.8.15** — the 0.8.14 retests (§34 – §34j: N140 – N175, N44) and `kb_verify` I13 fixed at their cause, with three independent reviews of the work and of their fixes. **After the update, once:** three indexes change format and are reported as `rebuild_needed` — each answers as it is **until you approve** its rebuild: Infosys 7 (`kb_rebuild(index='infosys')`), ESI 2 (`index='esi'`), manuals 6 (`index='manuals'`). Infosys (format 7): a page's kind comes from its own evidence, never from its samples — an FB whose sample declares a STRUCT stays an FB, operator pages are `Operator`, method pages `Method`, "returns TRUE/FALSE" is a BOOL return; the header in a page's Syntax section counts, also `FUNCTION BLOCK X` written with a space (N150, N151, N157, N172). Symbols documented inside a page (N170): `## Method X` sections with parameter tables are methods with a declaration reconstructed from the tables (RecipeManCommands' methods), the FB the text names gets its method list, IEC constant tables are `Constant`s with value, type and GVL (`ERR_RECIPE_NOT_FOUND` → `16#4003`), the build log counts them; a name only a title has as a word answers that page (`match: title`, outline). A page whose Syntax section declares another name than its title: the header is its declaration (`declared_as`) only when nothing contradicts it — no other page of its library documents that name, its tables document nothing the header lacks, it does not derive from the page's own symbol (tf8560's `ActualsActuator` declares `FB_ActuatorActuals EXTENDS FB_MotionActuals`, and `FB_ActuatorActuals` finds it); otherwise it is another symbol's header, Beckhoff's copy of a neighbour's (`F_TimeToSeconds()` shows `FUNCTION F_SecondsToTime`), and `syntax_header_note` says why. 'Definition:' under a Syntax heading no longer hides the declaration below it; SEL's syntax keeps the lines its 'means:' introduces. `FB.Member` follows the FB's definition location, EXTENDS and IMPLEMENTS (`FB_TcMessage.Send` → `I_TcMessage`'s, `defined_in`), in the owner's library first (N158); the near list offers the owner and where the member comes from (N159); an owner documented differently by several pages is not guessed — the note names the `library=` that finds the member; `FB_X.Member` finds member pages titled `LookUp()`, `SendAlarm()`, `GetHMI (4.1)`; member pages below two different symbols are never one document (`FB_JsonSaxWriter.AddKey` / `FB_JsonSaxPrettyWriter.AddKey`). Search: IEC keywords in capitals (THIS, FOR, BY) and operators as written (`REF=`, `THIS^`) are searched and put their page first — an acronym (PLC, LED) is a word like any other (N148, N149); `words_ignored` lists only words not used (N166); `infosys_search_symbols` takes globs (N161). Paging (N167): `infosys_search`, `docs_search`, `hw_search`, `manual_search` / `manual_page` page with `offset` / `next_offset` in one order; a limit above the maximum is used as the maximum and said (`clamped`); CLI `search` / `show --offset` (N168); ranges in `-h` and the MCP schemas (N169). `infosys_get_page` reads like `docs_page` — `section`, `offset`, `outline`, one `max_chars` range 200 – 60000; the skill takes `library=`; an MCP not-found names what is near and how to search (N160). kb_search (N143 – N147, N171): results and notes by kind, members mapped to their owner, the project's own units first, punctuation-joined tokens searched as a sequence, `changed_files` / `added_files` / `removed_files` when the project index is stale, one-letter message names ignored, a unit whose name holds none of the words no longer ranks by its name. ESI (format 2): sub-index in hex (`8000:11` = 17, N152), `revisions_available` (N153), decimal product codes (N154), one answer envelope for MCP and skill (N155), modules found by their devices (N156); a CoE object defined twice in one dictionary is one object — EL6653 and EL6653-0010 listed 17 objects and counted 19 (Beckhoff's EL66xx.xml repeats #x1C32 / #x1C33); a repeat that differs keeps the first and says so (`defined_again_note`, also in `hw_lookup`'s object list); `hw_search` pages by title in SQL and reads a page in two full-text queries (limit 100: 3.7 s → 0.1 s). Manuals (format 6): product codes of every Beckhoff family (ED2008, AL, AA, PS, ERI …) — `manual_product` and `hw_lookup` answer from the page text meanwhile, never "copy its PDF" for an indexed manual (N163); hex / CoE index forms in any notation (N164); unit symbols as written (°C, mV/V, µs, N165); `manuals_db.py` search / product / toc / docs print the skill's commands. XAE: a modal window of any class is detected — main window disabled, the window it owns read through UI Automation (N173: About, Options), also with a hidden main window (`xae.visible=false`); "Call was rejected by callee" is "XAE is busy … did not run this call"; every COM access goes through the retry; crash tracebacks in `~\.twincat_mcp\logs\server.log` (N174); an interface replace is checked against the XML's methods (N175); a listing that fails before a replace import imports nothing (N44). Server: the connection instructions say when they were read and that `kb_status` is current, and are read again at each initialisation (N162). Scope View log (N140): its NUL padding is no text, the mark of a call is where the log's text ends, a rotated log is told by its beginning, `new_bytes` counts text. Multi-PLC: a symbol of another PLC project names the PLC project looked in and where the name is (N141); a `<PLC project>@Name` pin names that PLC project, and a file several PLC projects compile is analysed in each and answered once when the results agree (N142); `proj_variables` names the PLC project it walked and resolves types in it; `proj_list` / `proj_search` / `proj_references` items carry `plc_project`; `proj_validate_xml(fix=true)` keeps one `.bak` per file per call. `kb_rebuild` gives the build's own pid (`launcher_pid` for a venv launcher), `kb_status` names it `process_pid`. Web cache: a lookup reads the index again when the background indexer changed it during the call (`infosys_declaration` answered a title page while the default books were being indexed). Not in this release: the error index (N125, 0.8.16).
**0.8.14** — the 0.8.13 retests (§33, §33b, §33c, §33d) fixed at their cause: N127 (S1), N116–N124, N126, N128–N138, N99b, N103b, the Scope and multi-PLC audits, the knowledge-base agent findings, and what `kb_verify` found (search by a title that is the query; page ids that are not numbers). **After the update, once:** the Infosys index changes format (5 → 6) and is reported as `rebuild_needed` — it answers as it is **until you approve** `kb_rebuild(index='infosys')` (or `tools\infosys_local.py index`); format 6 stores each page's place in the Help Viewer contents (N126) and reads pages in their own charset (N138). Folders indexed by `kb_add_project` before need nothing (their stamp is compared the old way until the next `kb_reindex`). Knowledge base (N127, S1): `kb_set_location` (with a path), `kb_add_project`, `kb_remove_project`, `kb_reindex` are refused in readonly mode and audited (ok / error / refused) like every EDIT tool; `kb_rebuild` is refused in readonly and still asks in every other mode. `kb_add_project` refuses the plugin's folders, the knowledge folder and a folder inside or around one already indexed (N128); a new folder is registered in the transaction that stores its units; one change of `knowledge.db` at a time (`index\knowledge.db.lock`, a second is refused with what runs; `kb_set_location` waits for it); `kb_status` / `kb_search` never create `index\` or `knowledge.db`. `kb_set_location`: a file is "a file, not a folder", `\\?\` paths are local paths, cloud folders are recognised by folder name (`D:\Toolbox Drive` is no Box Drive), choosing a drive root is refused, `path` + `use_fallback` that differ are refused (N136). Project sources (N132): `.Tc*` files are read as XML is — BOM, UTF-16, the declared encoding; a file that is not valid UTF-8 and has no UTF-8 in it is read as Windows-1252 and said so; files that could not be read, read notes and files no PLC project compiles are named in `proj_scan`, `kb_add_project`, `kb_reindex` and `kb_status`; code and comments are indexed whole; `proj_set_code` and `proj_validate_xml(fix)` write a file back in its own encoding and do not edit one read with a note. `kb_status` gives each folder's state and `kb_search` lists `stale` folders (changed, renamed, deleted since indexing; `.tsproj`/`.xti`/`.sln` count) (N133). `kb_search`: the unit that declares the name first (`declares`, N131); `kinds=['POU']` (N134); OR / AND / NOT / NEAR and operator characters are said not to be operators, single characters are not searched among other words, `_` alone is refused, a phrase in a string literal is not "comments" (N135); an error message is decided by its code — `ADSError 1808`, pyads' `ADSError: … (1808).`, `error 1861`, `error no. 1861`, `nErrId := 1861` / `16#745`, `0x98110745`, `ADSERR_…`, a compiler message — not by the word 'error', a number or a hex number anywhere, and the names of the code in it are searched (`ADS error 1808 fbCounter`, N129); ADS notes need a message about ADS ('not permitted', 'timeout elapsed', `nCycleCount 0x705` of any program no longer get one), name the code and symbol (checked against the local ADS return-code table), and an error the plugin has no note on is `known_error: null` with what is covered — never searched as words (N130); 'not logged in' is not the logged-in note, and errors the plugin composed itself are not annotated. XAE (N116, N120, N123, N124, N42): every retried COM call takes the System Manager / tree item / Scope object of the live shell (a solution reopened in the same shell failed the first call); an action that must not run twice (activate, restart, PLC online commands, create / delete / import, shell commands, Scope actions) is never repeated — not after a busy read-back (an online command went on to `PLC.Download`), not on a re-attached shell after the shell went away during it, not after a stale-object error ("may have acted, not repeated"); before an action a stale System Manager is refreshed by a read; `solution_mismatch` and `reattached` are on the result of every tool that used the shell; `xae_build(save_first)` finds a property accessor's file and drops edits of objects deleted since; an XAE token's scope is the machine. Project: a pin (`Plc2@MAIN`) names the PLC project, not a substring of the path; with several PLC projects, a port means its PLC project in every reader — also when no name is declared twice (`proj_variables` port, CONSTANT check in full mode, `PRG_B` of Plc2 on 851), a pin still names another, and a file linked into several PLC projects belongs to each; the PLC projects and files are read in one order on every file system; ENUM / STRUCT return types (N137). Scope (N117, N119): `scope_view_log` reads the padded `[ ERROR ]` tags; `rec_stop(also_scope)` / `scope_record save` compare the CSV of the saved `.svdx` with XAE's in-memory export (`cross_check`); exports go to a work file and replace the CSV only when complete, a sealed CSV is never written over; a `.svdx` still being written is not sealed into the link. Docs: methods and properties link to their FB (`parent` / `children`, `FB.Member`, N126); the skill's `docs_declaration` gives the members like `infosys_declaration` (N103b); one source page placed twice in a book is one hit and the defining table comes first (N99b); a number that is the query is a code, and a code next to its word is found among other numbers (N121, N122). Two independent reviews of the release, and a review of their fixes, found the points above beyond the retest list (among them: the stand-in database of a first read left the first `kb_add_project` without a search table; `ERR_…` identifiers, numbers and model numbers were taken for error messages; decoding by a single valid-looking byte pair). N125 (error descriptions from three places) is the error index of the spec — not in this release.
**0.8.13** — the 0.8.12 retests (§31, §32) fixed at their cause: N116 (S1), N113, N114, N103b, N105, N117, N118, the Scope View log, and the polish items. Nothing to rebuild after the update (no index format changed). XAE (N116, S1): the XAE tools are bound to one solution — the configured `xae.solution_path`, the one passed to `xae_attach`, or the only one open — and every call checks that the shell still has it open; when it was closed they re-attach only to a shell that has that solution, and otherwise refuse ("the solution … is not open in any XAE shell"), never acting on another shell's project or target; `xae_attach` never falls back to another solution and never picks one of several when none is configured; `xae_status` reports `solution_bound`, `reattached`, `solution_mismatch`. Project (N113, N114): the PLC projects of the project are those its `.tsproj` names (inline or in `_Config\PLC\*.xti`, with tree name and ADS port) — a `.plcproj` it does not name (the folder of a PLC project removed from the tree) is not part of the project (`plcproj_not_in_project`, its files in `not_in_project`, its tasks dropped); when the `.tsproj` cannot tell, every `.plcproj` counts and `proj_scan` says why; a `.tsproj` / `.xti` change rescans. Every file's units are kept, so MAIN of Plc2 no longer replaces MAIN of Plc1: `xae_build(save_first)` checks an edit in the PLC project its tree path names (it refused every build with two MAINs), and `rec_start`'s task check takes the PLC project on the ADS port it records (it said "task configuration not available" with two MAINs, and with one after Plc2 was removed). With names declared in several PLC projects nothing is picked by chance: lists, searches and references show both (`proj_list`, `proj_search`); a name in two (`MAIN`, `MAIN.Act`, a method name alone) is refused unless pinned (`Plc2@MAIN`), for writes too; symbol resolution, `proj_variables`/`proj_vs_runtime`, pointer aliases, interface and CONSTANT checks use the PLC project on the connected ADS port (the CONSTANT check refuses in guarded mode when that cannot be told); `proj_lint`, `proj_call_graph`, `proj_interface`, `proj_validate_xml`, `tcunit_scaffold` and the `wf_*` workflows run inside one PLC project — a whole-project lint is each PLC project's, merged, every finding tagged `plc_project`; a `.tsproj` reference that cannot be followed keeps every `.plcproj` (said in `plc_projects_note`). Docs: the skill's `docs_declaration` gives `kind`, `return_type` and the parsed `declaration` with its header — the same answer as `infosys_declaration` (N103b: a reader of the skill never saw `FUNCTION MEMCPY : UDINT`); a symbol's library names query words too (N105: "EtherCAT slave state" lists the Tc2_EtherCAT slave-state symbols first, FB_EcGetSlaveState 5th — no word of the query tells Get from Set, Req or the ST_ type). Scope (N117, N118): `rec_stop(also_scope)` and `scope_record save` export the CSV from the saved `.svdx` with Beckhoff's export tool — XAE's ExportCSV right after SaveSVD lacked the end of the file — and use ExportCSV only without the tool, flagged; whether the saved file still grows after SaveSVD returns is watched and reported; a read-only `.svdx` (rec_stop seals it) is exported from a byte-identical writable copy (the tool exited 0 without a CSV on it; that read-only is the cause is not proven), and a run without a CSV says exit code, output, size, read-only state; the ERROR entries `TcScopeView.log` gets during these calls are in the result (`scope_view_log`, `[scope] view_log`). Scope robustness: file errors and undecodable tool output are messages, a failed Scope part of `rec_stop` keeps the stopped recording's result, a non-zero exit with a CSV is flagged, a log without `[ERROR]`-style tags gives its 'error' lines and says the format was not recognised. Two independent reviews of the work and of their fixes found the points above beyond the retest list. Polish: `xae_plc` with several PLC projects and none named says that first (it began with "needs a confirm_token … target ''"); `xae_plc start/stop/reset` while logged out is refused with "log in first" (it returned and did nothing); `kb_status` → `builder_upkeep` is labelled as what the server start did; `label_reason` names every cause of INCOMPLETE in both engines (a RUN→CONFIG→RUN gap was only in `errors`).
**0.8.12** — the EtherCAT device descriptions get an index of their own, and the 0.8.11 retests (N37, N50, N93–N111, D19) fixed at their cause. **After the update, once:** the Infosys index (format 4 → 5) and the manuals index (page text format 4 → 5) are reported as `rebuild_needed` and **used as they are until you approve the rebuild** — `kb_rebuild(index='infosys')` / `kb_rebuild(index='manuals')`, or `tools\infosys_local.py index` / `tools\manuals_db.py update`; the ESI index `index\esi.db` does not exist yet, so its first build starts by itself (server start or the first `hw_*` call); `knowledge.db`'s search table is rebuilt from its stored units at your next `kb_reindex` / `kb_add_project` (seconds, no file read again) — until then `kb_search` uses it as it is and says so. ESI index (`index\esi.db`, `twincat_mcp/esi_index.py`, builder `esi_db.py`): every ESI file of this PC — the `.xml` files under `manuals\` (e.g. Beckhoff's ESI folder in `manuals\Beckhoff_EtherCAT_XML`) and TwinCAT's `Config\Io\EtherCAT` — read once: devices with PDOs (per-channel templates, gaps kept), PDO assignments with DC mode and init commands, slots resolved to their modules by rule, CoE dictionaries (sub-indices, array elements, decoded defaults, enum texts), diagnosis catalogues, modules; duplicates across folders by the vendor's FileVersion (`also_in` / `differs_in`); streamed device by device. `hw_lookup` reads it; new `hw_search`, `hw_object`, `hw_diag`, `hw_module`, and the skill's `hw_*` commands; `knowledge.db` no longer holds ESI rows (its old table is listed by `kb_status`, never dropped). Infosys format 5: a page's kind and return type come from its own evidence (N103 S2: `FUNCTION MEMCPY : UDINT` written as a heading, or only a return-value table, was a function block with no return type), a title that is a sentence documents no symbol (N101: "Attribute 'linkalways'" was a GlobalVariable), and every title's identifier words are indexed (FB_SocketConnect: socket, connect). One rule for which page documents a name (N100), shared by `infosys_declaration`, `infosys_get_page` and the skill's `docs_declaration` / `docs_page`: one page in several books is one answer (`same_page_in`), a C++/.NET page is no IEC symbol, library names compare without case, and different pages of one name — two libraries, or two methods of one library — are refused as ambiguous. One search for both readers (N105, N99): `infosys_search` and `docs_search` are the same code — identifiers as words, titles that name the query first, an error code's defining table (hex and decimal in one row) first (`code_note`), copies listed once. Measured on a copy of the real index with the format-5 words column: of the retest's four misses, FB_FileGets (2nd), FB_SocketConnect and F_GetSystemTime (1st) are now found, "EtherCAT slave state" still lists ST_EcSlaveState / FB_EcSetSlaveState before FB_EcGetSlaveState, and `0x745` lists the ADS return-code table first; on the format-4 index as it is, until the rebuild, the ordering helps less (identifier words are not indexed there). `kb_search` (N102): names and identifiers count as their words (FB_AlarmList: alarm, list), a unit whose code holds the words comes before a comment-only match (`match_in`), exact and prefix matches are both listed; one stop-word list for every search. The build lock is probed with a shared lock (N104: two readers probing at once saw a phantom "build RUNNING (0 of ? pages)"). Project (N106): the project is what its `.plcproj` compiles — a `.Tc*` file no `.plcproj` lists (a backup copy, an export) is no unit and is named in `not_in_project`; a `.plcproj` change rescans. Recorder: `rec_compare` works on native-engine recordings (N107 S2); `rec_verify` explains a counter that went back from the task's cycle counter recorded with the data, never "TwinCAT restart" for a program reset (N111). `ipc_eventlog` resolves the boot folder (N110 S2: `boot:` and `boot` are one prefix). `wf_debug_symbol` follows the symbol's scope (N111). `exp_run(dry_run)` archives nothing and says why its plan is short (N108). `tcunit_scaffold` outside the PLC project is not gated by config mode (N109). An output folder that does not exist yet is created inside the allowed roots (N98). `proj_lint`: an instance whose creation runs FB_init (a TcUnit suite) is not "never used". Manuals (N37, format 5): text below 1 pt is not page text. N50: an alias of a pointer is decoded from the project source. N93: a restart compares the application it read. N94: an unticked confirmation says so. N95: UTF-8 comments. N96: `kb_rebuild` checks before it asks. N97 (S2): PLCopen replace keeps foldered objects in their folder. D19: ESI templates. An independent review of the release found, and this release fixes: an ESI description replaced by a newer file left its objects behind (searchable, counted); every `Profile` of a device is read (their dictionaries joined, differences named); `--no-twincat` is recorded (no false OUTDATED); a work file left next to a current index is discarded; `hw_lookup` counts every match (not the first 200) and `hw_search` counts every variant; `%` / `_` in a device query are literal; an out-of-range number is refused with a message; `hw_object` / `hw_diag` / `hw_module` start the first build too, and an `.xml` that is no ESI starts none; two pages of one book are never "one page in several books" (two FBs' methods of one name stay ambiguous); `truncated` says whether hits were cut, not whether copies were merged; a decimal number is an error code only next to a word that says so (`error 1861`, not `port 851`); `wf_debug_symbol` keeps bare uses of a global variable (a GVL without `qualified_only`) and reads `aFill[i + 1].x` as a qualifier; `kb_search` with quoted phrases only lists units with every phrase, and units whose code holds the words are read first; `rec_verify` counts resets it could not decide and those not assessed separately, and no longer slows down on long recordings with many repeats. Builders: `manuals_db.py` 6, `esi_db.py` 1 (new), stub 2.
**0.8.11** — the 0.8.10 bug list fixed at its cause (N5, N37, N75–N79), the long-standing ADS stale-address defect, and permanent ground rules for the knowledge base (`docs/KNOWLEDGE_BASE.md`). **After the update, once:** the knowledge folder's indexes are moved into `index\` (a rename, nothing copied or deleted); the Infosys index (format 4) and the manuals index (page text format 4) are reported as `rebuild_needed` and **used as they are until you approve the rebuild** — `kb_rebuild(index='infosys')` / `kb_rebuild(index='manuals')`, or `tools\infosys_local.py index` / `tools\manuals_db.py update`; `helpviewer\infosys_en.db` (the builder's old second index) is no longer read — `kb_status` lists it with its size, delete it yourself. ADS (root): pyads keeps each name's address for the life of a connection, so after a download, an online change or a restart with another build `ads_read_many`, `ads_watch`, polled recordings and the restart witness read the old offsets — another variable's bytes with no error. Every read and write by name now goes through a symbol handle made in the same request as the symbol version (0xF008): a handle names the variable, so the runtime answers for that variable or refuses the handle; a value of another symbol version is never decoded (renewed and read again). One sum request per call, per-item error codes, the same value conversion as a single read. `ads_dump` (N76): `max_total_mb` is the size of CSV + JSON on disk, known exactly before each row is written (the row, the tree after it, and the largest ending still possible) — nothing estimated; a limit below the files' own frame is refused; the result names `written_mib` and `limit_counts`. Restart proof (N75): the after-witness is taken in RUN from the runtime that came back, a counter that ran on outweighs one poll without an answer, the compile timestamp is compared by its number, an unreadable value says why; `steps.restart.duration_s` lasts until RUN was seen again (N5, `command_s` the shell part). Confirmation tokens (N78): `tc_request_confirmation(action='xae_plc', args=…)` gives no token for a PLC project the open solution does not have. Recorder (Python): rows are written in PLC-time order, the root of rows past a later end. Knowledge base: one layout (`index\infosys-<lcid>.db`, `index\manuals.db`, `index\knowledge.db`; `manuals\` and `sop\` are yours and never written), one Infosys index — the builder runs the plugin's own extractor, and the `infosys_*` tools and the twincat-docs skill read the same file; text normalised once when indexing (links as page addresses, table cells as plain text with the type's page as `type_ref` — N77, no invisible marks, no image placeholders); every index records its format and source, and a test fingerprints the code behind each format; the first build of a missing index starts by itself, every later one only after your approval (`kb_rebuild`, new, control class); builds write `<index>.building` and swap when complete, the ESI part of `knowledge.db` changes in one transaction; the plugin deletes nothing. Manuals (N37, format 4): text of font size 0 or fully transparent is not page text — the invisible copy of the running header under every heading no longer doubles headings and toc titles; an update of a complete index changes nothing. Docs search (N79): a query of stop words only is refused, as in `kb_search`. Builders: `infosys_local.py` version 3, `manuals_db.py` version 5. ADS: one STRING codec, Windows-1252, for every read and write (N91: `ü` failed or was written as two bytes; a character a STRING cannot hold, or a text too long, is refused before anything is written). D5: BOOL members read as 1/0 — the 4026 datatype table aliases BOOL to BYTE and the decoder followed the alias; a scalar alias now decodes by the first elementary name of its chain (BOOL stays BOOL, `TYPE T_Level : UINT` is a UINT), else by the runtime's data type id — an enum with an unsigned base reads unsigned. Values as a read returns them (an enum `{value, name}`, a TIME `{raw_ms, text}`, arrays of them) are accepted back by writes, snapshot restores and comparisons, and `ads_wait_until` sees an enum condition that already holds. N50: an unknown type name was sized by guess; the datatype table decides now, so a `TYPE PT_X : POINTER TO …` alias is an address with the pointer note in `ads_read`, `ads_read_many`, `ads_dump` and recordings. Recordings (R43): a recording is of one program — a block sample is written only once a symbol version read taken after it returned the version the recording started with; a download or online change ends the recording at the last verified sample (`program_changed`), a restart of the same program continues (`program_watch.notes`), samples never verified are counted (`unverified_rows`), not written; the start refuses addresses resolved against another version. A sample that reaches the recorder after its last drain is counted in `after_limit`, none twice. Native engine (N84): rows before a trigger window are counted (`rows_before_window`) and skipped by the readers and `rec_verify`. Confirmations (N89, N90, MCP SDK 2.3): on MCP 2026-07-28 a server cannot call the client, so the in-chat confirmation never reached you on 0.8.10; the question is now returned as an input-required result (sealed by the SDK, bound to the tool, its arguments and 600 s) and elicitation/create is kept for earlier revisions; a refusal says what blocks the prompt (the client declared no elicitation, or the transport has no channel); `tc_doctor` → `elicitation` (works, route, why). Every error reaches the model with its text; a defect says its type and is logged under a crash id. XAE (TE1000 Automation Interface): `xae_plc` uses ITcPlcOnline on the PLC project first (Login with its flags, Logoff, Start, Stop, Reset cold/origin, the state read back — `method`), then ConsumeXml (a refusal carries GetLastXmlError), then the shell's commands; `xae.silent_mode` (opt-in) sets TcAutomationSettings.SilentMode for one confirmed activate / restart; `xae_check_all_objects` (N82) clears the Error List and reads only the Build pane lines of this run, a failed check returns its findings; a graphical body (CFC, FBD, LD, SFC) is recognised by ITcPlcImplementation.Language before any text is compared (N81); PLCopen import options come from the live type library (the two TE1000 pages disagree on REPLACE / RENAME) and the value used is in the result; `xae_pou_create(kind='property')` creates the Get/Set accessors (N80); activate / restart compare the application's compile timestamp before and after (`plc_application`); `xae_tree_get` names TIRT for tasks (#28). Dialogs (N85–N87): a call held by a modal dialog it opened is reported after about 2 s with the dialog's title, text and push buttons, every XAE call is refused until it is answered, and the call's late result is in `xae_dialogs`; check boxes and radio buttons are listed as `options` and never clicked (N86); a dialog already open when a call starts is reported (N87); `xae_deploy` stops before its next step (with what was done) when a step left a dialog waiting; a progress window (Cancel only) stops nothing. An ITcPlcOnline command whose state does not follow within 5 s is reported as possibly done and never sent again through another route. Project (N88, N92): the project index is built aside and published whole — parallel `ads_dump` calls no longer report "no project configured", and no tool sees a half-built index; a PROGRAM or GVL of the source with variables but no symbol on the target is named by `ads_dump` (`not_on_target`) and `proj_vs_runtime` (`roots_not_on_target`) with its cause, and `xae_build` names GVLs nothing in the source references (`link_warning`: TwinCAT does not link them; `{attribute 'linkalways'}` does); with several PLC projects only the one whose symbols the target has is judged, and a name declared twice is never called missing; a `//` or `(*` inside a string literal no longer hides code from this search or from `proj_lint`. `ads_dump` (N47): a cut dump fills its limit (the 0.8.10 estimate left files at 64–67 % of `max_total_mb`). Infosys: a page is answered from the Help Viewer package while the first index is still being built (it failed with "no Infosys index yet" once converted). R30: the `infosys_*` descriptions describe the configured source. `kb_status` → `builder_upkeep.pointer`: written / unchanged / why not. D19 still needs the ESI file.
**0.8.10** — 0.8.9 retest fixes at their cause (N38–N69, R40, R24; D19 still needs the ESI file) and an independent review of every fix. **After the update, once:** the Infosys index is read again in the background (R24 parser and code-space change, INDEXER_VERSION 3), and the manuals are read again (TEXT_FORMAT 3) — the first docs lookups say "rebuilding" until it is done. XAE: `plcopen_import(option='replace')` checks the copy against the imported XML (TwinCAT's plain-text declarations and ST bodies — methods, actions, property Get/Set — are the oracle, not the copy TwinCAT made), renames the declaration header back (`PROGRAM PRG_X_1` → `PRG_X`; an unrecognised header, or the copy's name anywhere the XML does not hold it, writes nothing), checks the declared name after writing independently of the written text, and keeps every copy whose original is not verified (N38, N43–N45); a body that is not ST (CFC, FBD, LD, SFC, IL) is refused with the copy kept; a 'replace' whose project tree cannot be listed is refused before anything happens. `xae_plc` resolves the PLC project once and every route (ConsumeXml, named DTE command, generic command) acts on exactly that project; with several projects and none named, or a project list that cannot be read, nothing is guessed (N41). Confirmation tokens: the server computes the target every guarded tool checks (`xae_plc` → `<action>:<project>@<NetId>`, `xae_command` → `<command>@<NetId>`, `xae_boot_project`, `xae_tree_delete`), and `tc_request_confirmation(action, args=…)` uses the same rule — a token can no longer be issued for one project and used on another (N40, N42, N69). Restart proof (R40): a witness (mode, task 1's CycleCount, the application's compile timestamp) is taken before activate / restart; proven by a poll seeing another state, a start from CONFIG, a counter that went back, or a changed timestamp; "in RUN, restart not proven" is reported as that, never as "not in RUN"; a frozen counter is "cannot tell", not a restart. Infosys (R24): Help Viewer code keeps non-breaking spaces; they are normalised where the page is read, so `infosys_declaration` gives `FUNCTION F_GetTaskTime : ULINT` without a `VAR_OUTPUT`. Manuals (N37, N66, TEXT_FORMAT 3): running headers, footers and margin labels are found by their place on the page, not by repeated text — the section-title header and a footer with the page number first are removed, table header rows repeated at the top of continuation pages stay; a space set in its own font is kept ('Note that', not 'Notethat'). Knowledge folder (N64, N68): the builder stubs replace only files this plugin shipped (sha256 list, any line ends); your own scripts (`*OLD.py` …) are left alone; the pointer to the plugin is written before the stubs; a finished Infosys rebuild that is being swapped in reads as running, not as 'waiting — run index again'. Knowledge base (N70–N74, from your retest SOP): `kb_search` knows ADS errors in any notation (`ADSError 1861`, `ADS error 1861`, `ADS-Error: 0x00000745`, `0x745`) and the compiler messages identifier not defined (C0046), cannot convert type (C0032) and unknown type, with the quoted names in cause and fix; an error message (a quoted name, an ADS code, a hex number, a C-number) is searched in the code only for the names it quotes; stop words are not searched (a query of nothing else is refused) and in the some-words tier words on no unit or on most units are not used (each hit names its matched words); a copy name no unit has (`PRG_TcUnitRunner_9`) falls back to the name it was copied from and says so; `hw_lookup` takes `detail` = `newest` (default: PDOs and CoE objects of the newest revision of each type, each type's newest listed first) / `summary` / `full`, and `revision` as shown (`#x…`) or as a number; `infosys_declaration` gives an FB's or interface's `methods` and `properties` as their own lists (by the page's headings), no longer inside `description`. Docs search (N65, N67): tiers "all words found in the index" → "some words" (which words each hit holds; stop words, words on no page and words on most pages are named and not used), a page titled like a query word first; a product code without its revision falls back to the base code (`matched_as`). Launcher (N46): a new venv is built beside the live one and swapped in on a later start when another window still runs the server; an older pending build never replaces a newer install; a health-check timeout is "unknown", not "broken". `ads_dump` (N39, N47–N55): every root read in bounded pieces (no 1802 for a big variable), a root that cannot be read named in the result, the CSV (`NOT READ`) and the JSON footer (`complete: false`); CSV and JSON come from the same decoded leaves; CSV header `path,type,value,note` (why a cell is empty: not expanded, no storage, outside the symbol); a NULL pointer is the address `0x0000000000000000`; sizes in MiB; both files moved into place or neither, a partial commit reported as such; file names unique per dump (`-2`, `-3` in the same second), an existing file is never overwritten (not even one that appears during the commit); a leaf the datatype table places past the symbol is never read from the neighbour's bytes — it is listed in `unreadable_leaves`, marked in both files, and the dump is not `complete`. `ads_leaves(with_values)` reads only the head of a value that is not expanded and says why a value is missing (`value_note`). Writes: a `POINTER TO` alias (`TYPE PT_X : POINTER TO FB_X`) or an alias of an interface is refused like the type itself. Recorder (N56–N63): `rec_stop` ends at the newest PLC time received and waits (at most `max_delay_ms` + 1 s) until every block has been seen at or past it — footer `stop_on_request`; in the Python recorder every other end at a PLC time (duration, `post_trigger_s`, `stop_when`, `max_events`) waits the same way (footer `stop_at`), so another block's samples still in transit are not lost (rows a block that ran ahead wrote past an end set later are counted as `rows_after_window` and skipped by `rec_read`, `rec_stats`, `rec_export` and `rec_verify`); `rec_verify` checks the file independently (its own window, one period of allowance, `block_ends`), reads a ≤ 0.8.9 stop as INCONCLUSIVE instead of lending it a tolerance, says INCONCLUSIVE for a change / poll recording without a `counter=` check that ran (intact, but nothing shows it complete), and fails on a block that never delivered or on symbols that could not be subscribed (a block that could not be registered with `allow_partial` is not counted as a block that never delivered); a block with one sample or none is counted with the other blocks' period (it read COMPLETE); counts in integer 100 ns ticks in both engines; a trigger with `pre_trigger_ms` 0 checks the head; a level `start_when` that was true only during the warm-up is reported (and a later fire is not reported as delayed); exports are written to a temporary file and moved into place, never over the recording; the recording's `cycle_ms` is compared with the task that runs each recorded program (per PLC project). `TestBench.expect` compares addresses as addresses (`null` matches a NULL pointer).
**0.8.9** — 0.8.8 retest fixes (R42, #10, R7, R41, R15, R23, R11, R24, R32, N31, N32, N34–N37), the knowledge-folder builders in the plugin, and **Python 3.12 as the one version** (minimum and the only one tested). Python: `requires-python >= 3.12`, `tomli` dropped; the server, `launch.py`, `launch.cmd`, `install.cmd`, `kb_docs.py` (was 3.9) and the builders refuse an older Python with one line naming the version found; a venv an earlier plugin built on 3.10 / 3.11 is rebuilt with the configured Python on the next start; CI runs Windows and Linux on 3.12 only. `ads_dump` (R42, S1): a `POINTER TO` / `REFERENCE TO` / interface is dumped as its address and never expanded as the target (TcUnit's 1,000 `POINTER TO FB_TestSuite` had produced 4.7 GB of fabricated members); the pointer width comes from the declared size (4 bytes on a CX9020); one leaf budget (`max_leaves`) for CSV and JSON, `max_total_mb` measured on the bytes written, the root being written included; the JSON is written root by root (a cut root is `{"_truncated": true, "_leaves": …}`, NaN/Infinity as strings); the project's own programs and GVLs first, library globals (`GVL_TcUnit`, `Global_Variables` …) last; cancellation is checked inside a root, and a dump whose call already ended (timeout) deletes its `.partial` files instead of renaming them; the timeout names the phase, root, leaves and MB instead of router/firewall; the result names `truncated_reason`, `skipped_roots`, `largest_roots`. XAE: the outer PLC node and its nested `<Name> Project` are one PLC project (#10 — no more 'ambiguous' and the slow DTE fallback); `plcopen_import(option='replace')` that TwinCAT turned into a renamed copy at the project root (`PRG_X_1`) is finished by the tool: both objects read and compared at every level, the copy's texts written into the original, read back and compared, the original restored if anything fails, the copy deleted only once its original is verified (R15); a 'replace' is refused when the project tree cannot be listed; `StartRecord` returning 1 is a note, not a warning (R11). Recorder: native level triggers fire only after every notification block delivered (or 2 s), so every block starts at the trigger cycle (R41); `rec_verify` uses the engine's tail rule after a stop on request (R7); `cycle_ms` below the fastest task is flagged with `fastest_task_ms` (N31); an earlier export at the default path is only replaced with `overwrite=true` unless the re-export is identical — `replaced` says what was replaced (N32). Infosys / docs: a name documented in several libraries is refused with every library and page instead of picking one (R23); a function's return value is no longer listed as `VAR_OUTPUT`, also with a reconstructed header (R24); type names wrapped in narrow table columns are joined when a page is read — on the page's own declaration or in a Type column — and zero-width characters dropped, without rebuilding the stored index (R32, checked on synthetic pages only); `hw_lookup` takes the order code out of the ESI type (`CX9020 EtherCAT slave`, N34); `near` answers from titles (N35); `docs_search` / `manual_search` fall back from all words to prefixes to some words, `match` says which (N36). `manuals_db.py` version 3 (N37): running headers, footers and margin labels are removed from the page text (also where glued to a heading), a heading wrapped over two lines is one toc entry; an index of the older page text format is reported `outdated` and the next `update` reads every manual again. Builders: the knowledge-folder builders ship with the plugin (`skills/twincat-docs/builders/infosys_local.py` version 2, `manuals_db.py` version 3). They need Python 3.12 and say so on an older one instead of a SyntaxError (version 1 claimed 3.9 but did not compile below 3.12); they find the knowledge folder like the other tools (`--kb`, `TCMCP_KNOWLEDGE_DIR`, `kb_set_location`, the folder above the script). Infosys index: a rebuild writes `<db>.building` and replaces the index only when complete, one build at a time (`<db>.lock`, heartbeat) — an interrupted `index --force` can no longer leave a half index that the next run calls up to date; a version-1 index is upgraded by the next `index`. The real state (complete / outdated / old format / incomplete / missing, a rebuild running / interrupted / waiting) comes from `stats --json`, from every `kb_docs.py` answer (`source.state`), from `kb_status` → `docs`, from `tc_doctor` → `offline_docs`, and from the instructions the server sends at every connection and reconnection. `search --book` / `--doc` filter before the row limit; `show` / `decl` refuse a name several pages share and list them; links are kept (table `link`, ms-xhelp ids resolved) and `docs_page` / `docs_declaration` return them as refs. Manuals: one update at a time, state and progress in a `meta` table, PDFs not indexed reported. CI checks `kb_docs.py` on Python 3.9 and the builders on 3.12 (and their refusal below). Docs: `skills/twincat-docs/builders/README.md`.
**0.8.8** — offline documentation as a skill, `kb_*` tools aligned with the knowledge folder, R33–R39. New skill `twincat-docs` (`skills/twincat-docs/`: `SKILL.md` + `kb_docs.py`, standard library, read-only) over the databases the knowledge folder's own tools build — `helpviewer\infosys_en.db` (`tools\infosys_local.py`, every Infosys page of the Help Viewer package) and `manualsdb\manuals.db` (`tools\manuals_db.py`, PDF manuals): `docs_search`, `docs_declaration`, `docs_page`, `docs_books`, `manual_search`, `manual_product`, `manual_toc`, `manual_page`, `manual_docs`, `status`, one JSON object each, with the builders' ranking, snippets and page references; a name in several libraries is refused as `ambiguous` with the candidates, filters run before the row limit (`total` exact), ranges are refused rather than clamped, invalid FTS5 is retried once as a phrase, `ms-xhelp` links open via the help id; the `twincat-mcp` skill and the server instructions send documentation questions there, never to `ads_*`. `kb_status` → `docs`: both databases with counts and package stamp, the PDFs not indexed / changed / gone, the folder's tools and SOPs, the exact command for this folder; `kb_set_location` names the documentation it finds and without arguments returns the current folder (R38); `hw_lookup` lists the manuals that mention the product code. Fixes: the whole `~/.claude/plugins` tree is refused as a location (R33); reads never write-probe, a choice probes under a name of its own (R34); one project per folder in any spelling, the label works for `project=`, `kb_reindex` and `kb_remove_project`, earlier double entries merge (R35); `kb_search` finds units with every word first, then every word as a prefix (`CounterMod` → FB_CounterModule), only then some words (`match` says which), `"quoted"` phrases (R36); punctuation-only queries refused with a message (R37); `kinds`, `project`, `limit` checked (R39); relative paths refused; a label shared by two folders is not removed blindly.
**0.8.7** — 0.8.6 retest and robustness pass (R1–R32, D46, D29, N29, #10/#45, D19). Safety of side effects: tools write files only inside known folders (recordings, project, solution, scope export, experiments, audit log, plus `[server] output_dirs`) and never replace an existing file without `overwrite=true` — `rec_export`, `ads_dump`, `ads_export`, `tc_session_report`, `scope_record`, `scope_export`, `tcunit_scaffold` (R4); `tc_request_confirmation` issues tokens only for real tool names (R2); `tc_session_report` returns a 25-line preview unless `inline=true` (R12). Workers: `ads_dump` runs on its own worker and writes `*.partial` files renamed on success; a queued call whose timeout ran out is withdrawn and says what the worker is busy with instead of cancelling the running job (R6). Recorder: every numeric `rec_start` argument validated before anything starts (R9); conditions refuse operator-led values, unknown types and bad literals and resolve enum names (R10); `rec_snapshot` reads the file for recordings of an earlier session (R13); counter checks per segment across PLC resets (D46); `armed` is false once the trigger fired or the recording ended, native engine included (N29); native tail slack after a manual stop (R7); `rec_profile_start` starts all groups on one PLC cycle via a shared `CycleCount` trigger, `align=false` for the old behaviour (D29). XAE: PLC paths split into the outer `ITcPlcProject` and nested `ITcPlcIECProject` item (R14); `xae_plc` login/logout/start/stop and Check all objects go through the Automation Interface (`ConsumeXml` online commands, `CheckAllObjects`) before the DTE commands (#10, #45); `plcopen_import` reports added objects and refuses a 'replace' that created duplicates (R15); COM methods invoked as methods (R11). TcUnit: results counted from the test cases, suite attribute mismatches reported (R3). Infosys: small lookup tables of what is indexed (catalog, code blocks, help ids) built once in the background — no page is re-parsed and no warm-up query runs (R18); code search normalises non-breaking spaces, `ignore_spacing`, validated languages (R17–R22); `ms-xhelp` links become page URLs, images and empty links removed, declarations tidied, quoted phrases kept, ambiguous declarations name `also_in` (R23–R32). Also: readable spec errors with line/column (R1), the 'datatype table unavailable' answers carry the config hint (R5), RPC 1797 explained with the parameter sizes (R16), unparsable ESI PDO rows carry the raw element (D19, parser v3).
**0.8.6** — 0.8.3 deep test, last part (D20, D25–D29, D32, D38–D43, #10, N19b). XAE: `xae_build` / `xae_diagnostics` parse the 4026 compiler format `File.TcPOU@Method(9) : error: C0185 …` (file, `object`, line, column, `code`; they were empty, D41); `xae_diagnostics` reads the Error List late-bound (the cached 7.0 DTE wrapper has no ToolWindows), copies the Error List view when the automation interface reports nothing (TwinCAT's own Scope / Measurement warnings), merges the Build pane and names each source (D20); `xae_plc` login/logout finds Solution Explorer the same way and selects the PLC project node, then uses 'Set as Active PLC Project' where the shell offers it (#10); `xae_status` with no solution says so instead of a raw COM error. TcUnit: `proj_libraries` scans the 4026 repository `ProgramData\Beckhoff\TwinCAT\PlcEngineering\Managed Libraries` (D38) and gives `effective` for profile-managed placeholders (the only installed version, else "profile", N19b); `tcunit_scaffold` arranges enum inputs with a real member (`E_Command.None`, not `E_Command.0`) and lists the stub tests that fail until written (D39); `tcunit_run` refuses before activating anything when the project leaves `GVL_Param_TcUnit.xUnitEnablePublish` FALSE, stops waiting when the PLC reports FALSE, and names the resolved `boot:` folder (D40); its token target is the NetId (D43); `xae_tree_delete` tokens name the item (D42). Recorder: `rec_pulses` `duty_percent` over complete periods (the idle lead-in counted, D26) and the open first run is no pulse; `rec_profile_stop` after the profile ended gives the final status (D27); `rec_profile_list` `updated` always ISO (D28); `rec_profile_start` reports each group's `start_offsets_ms` from the PLC timestamps (D29); `rec_snapshot` counts a finished native recording's events (D32). Infosys: the background index reports pages inside the current book and `seconds_since_progress` (D25).
**0.8.5** — 0.8.3 deep test, second part (D33–D37, D44, D45). `xae_build(save_first)`, `xae_deploy` and `tcunit_run` (which did not save at all; new `save_first`) run File.SaveAll, save each document/project the shell still reports unsaved, and check every `xae_pou_set` / `xae_pou_create` edit of the session in the source files — they refuse to build while one is missing (an external reload had discarded edits that were only in the shell, D44); `save` in the result lists what was dirty before and after. Experiments: a condition that already holds at a recording's first sample is its initial state, not an edge — no latency is measured from it (the first-sample "1 ms" values that passed a criterion, D34); a latency between symbols in separate change-mode notifications is not reported (timestamps up to `cycle_ms` off, order not kept — use cycle mode or one notification; `rec_latency` returns `timing.trust` exact / quantised / unreliable, D35); measures use the samples up to the start of the `after` steps (`last` read the reset value, D33); `exp_abort` still runs the current run's `after` steps, marks that run `aborted` and ends the experiment `aborted` also when it was the last run (`abort_received` shows when it arrived, D37); the 95 % CI of a count or duration is clipped at 0 and never narrower than the timestamp resolution (`ci95_note`, D36). `tc_doctor`: TwinCAT in CONFIG is reported as info, not as a problem (D45).
**0.8.4** — 0.8.3 deep test (D1–D31, N5, N27–N30, #17, #20, #28, #56, #78, A5). Recordings: a PLC STOP (cycle counter frozen while 1 ms notifications keep coming) is a `{"_stall":…}` line, counts as missing and labels the file INCOMPLETE with `label_reason` (D11); the native engine re-subscribes when every block went silent, e.g. after RUN → CONFIG → RUN (D13, `resubscriptions` in the footer); live status counts silent time as missing (D14) and stops on the PC clock when PLC time no longer advances (D15); a recording that ends by itself is sealed at once and a start-up sweep seals any left unsealed (N27); `rec_read(wide=true)` gives one row per PLC timestamp with null where nothing was sampled, `fill=true` marks filled cells (D9); counter completeness capped at 1 with duplicates reported (D12); `armed` false once triggered (N29), `values_per_s` counts array elements (N30); `rec_list` pages the session list too (D21). `rec_start(pattern=…)` selects exactly the leaves `rec_scan` lists: `*`/`?` wildcards, `[ ]` literal (`GVL_Sim.aCh[1].*`), a leaf matches when it or a parent matches (A5); `prefix` may name one element of an array of structs (D31). `ads_wait_until` / `ads_watch` use scratch recordings that are always removed, `interval_ms` honoured (D7, D6); top-level enums read as `{value, name}` (D4). `exp_run` writes the stimulus only after every recording delivers (D22). `xae_restart_twincat` / `xae_deploy` count a restart only after the target was seen leaving RUN and coming back (`modes_seen`, `transition_seen`; the first poll read the old runtime's RUN, N5/#4). ADSError 6 is explained for the port that was asked (D24). `proj_vs_runtime` gives no missing/extra verdict from a list cut at `max_items` (`complete`, `not_judged`, D23), keeps the PLC's and the source's spelling (#20). `xae_tree_get` walks the nested `<Name> Project` (#17). `ipc_eventlog` never guesses the boot folder and reports whether its temp file is really gone (#56). Infosys: one transaction per book, FTS optimised once, external-content FTS (10,000 synthetic pages in 11 s here, DB −30 %; not yet timed on the real package), CRC-based page identity so an unchanged package (or the same package on another PC) is never re-read, re-index only when the indexer version changes (an existing 0.8.3 index rebuilds once), progress names the book being indexed (D1, D25); `version` only from `Microsoft.Help.DisplayVersion` (D2); answers carry `doc_date` + `indexed_books_count` instead of the book list (D3). `proj_lint real_eq` ignores `:=` (D17); `proj_interface` inherited members have `file` (D16); `proj_variables` keeps the CONSTANT qualifier (D18); `hw_lookup` reads namespaced ESI files (PDOs, entries, CoE; ESI files are re-read once, D19). Scope found through `IMeasurementScope` (N28, not yet run on hardware). D8: the server already published `engine` / `also_scope` / `scope` (a regression test now pins them) — a client that does not show them is using a cached schema.
**0.8.3** — Infosys offline from the Help Viewer package. `docs.source = "local"` (default) reads the `BKINFOSYS3_*.mshc` TwinCAT XAE installs (found under `%ProgramData%\Microsoft\HelpLibrary2`, or `[docs] mshc`): every TwinCAT 3 book indexed in the background (default books first), pages keyed by their real infosys.beckhoff.com URL, no network call and no web fallback; results carry the doc version and the package date; a new package re-indexes, an unchanged one is never re-read; index next to the knowledge base. Missing package → the infosys tools say where they looked and how to fix it (`docs.source = "web"` keeps the website path). Infosys tools no longer crash when the index is disabled.
**0.8.2** — TwinCAT Scope next to the ADS recorder. `scope_export` runs Beckhoff's `TC3ScopeExportTool.exe` (`"svd=<file>" target=<csv> silent`, TE130x / TF3300 folders, `[scope] export_tool`) on a saved `.svdx`, never edits its CSV, and writes a signed `<csv>.provenance.json` (source and tool SHA-256, command line). Through the XAE Automation Interface (`IMeasurementScope` of a Measurement project): `scope_list`, `scope_record` start / stop / save (SaveSVD + ExportCSV into `<recordings>/scope`). `rec_start(also_scope=true, scope=…)` starts the Scope configuration with the recording; `rec_stop` stops it, saves `<id>.svdx`, exports `<id>.scope.csv` (falls back to the export tool) and writes a signed `<id>.scope.json` with both SHA-256s; the files are read-only. `[recorder] also_scope = "ask"` (default) asks the user whenever XAE has a Scope configuration open — by prompt, or with `allow_elicitation = false` by an error telling the agent to ask and call again; `always` / `never`. New tool group `scope`; 155 tools. The XAE Scope calls are late-bound against the documented interface and not yet run against a real Scope View.
**0.8.1** — recording authenticity (field report R1–R12) and the 0.8.0 retest. Both engines write `{"_gap":…}` lines where samples are missing (inside the run, before the first and after the last sample), a footer `label` COMPLETE / INCOMPLETE / NOT_PROVABLE, and a signed `{"_chain":…}` checkpoint (running SHA-256 + HMAC) every second, so a file whose footer never got written is still proven intact up to its last checkpoint. After a recording ends it is sealed: `<id>.manifest.json` (whole-file SHA-256, footer checksum and signature, summary, HMAC-signed) and both files read-only; `rec_verify` checks every checkpoint, names the first bad line, checks the manifest and reports `read_only`; `rec_delete` clears read-only and removes the manifest. Every export writes a signed `<export>.provenance.json` (source file and SHA-256, footer checksum, label, seal state, parameters incl. `fill`, exporter version, export SHA-256); CSV gets an exact `time_utc` column (7 decimals, integer maths). The header records each symbol's datatype GUID, base type and (structs recorded whole) member layout. Retest: N13 duration stop waits ≤ 1 s for in-flight samples up to the cut (lossless runs no longer FAIL by 3–10), N17 ADSError 6 explained from port and RUN/CONFIG, N18 PROGRAM calls in `proj_call_graph`, N19 `(System)` placeholders are info, N21 infosys names the books being indexed and the real backend, N22 BOOL writes accept only true/false/0/1, N23 `rec_capture` explains missing stats, N24 `edge_triggers` in the header, N26 project index scans serialised, #66 tcunit ProductVersion from the project. N25: `tcmcp-capture.exe` now ships in the plugin, built and tested by the release workflow (`release/v*` branch → exe + SHA-256 committed, tag, release with zip, `.mcpb`, `SHA256SUMS`, build provenance); the server refuses an exe whose SHA-256 differs from the pinned one; `/Brepro` makes the build deterministic.
**0.8.0** — native capture service (C++ capture spec). `tcmcp-capture` on Beckhoff's AdsLib (vendored, MIT) in TwinCAT mode over TcAdsDll.dll, bound at start so CI builds the exe without the TwinCAT SDK (`-DTCMCP_TCADSDLL_SDK` builds against Beckhoff's headers and lib, same layout asserts); AdsLib standalone elsewhere. Lock-free per-block rings, PLC-time merge, format-2 file byte-compatible with the Python recorder (spec test 10: identical rows and exports), SHA-256 + HMAC footer, head/gap/tail/overflow accounting, triggers and stop conditions evaluated on raw bytes, all-or-nothing subscription, clean stop when the server goes away. `rec_start` / `rec_capture` `engine` parameter, `[recorder]` config section, engine and reason in every response, `tc_doctor` → `recorder`. Python recorder: pre-trigger samples that are discarded no longer count in the block timeline; `window_start_ms` (trigger − pre_trigger_ms) and `missing_head`; `rec_verify` fails on overflow and on a FAIL verdict hint. Simulator backend + 15 tests for spec items 1, 5–10. 151 tools.
**0.7.2** — knowledge base and the 0.7.1 retest. Knowledge base (decisions of 2026-10-17): the database lives in a folder the user names (`kb_set_location`; checked: local fixed drive, not cloud-synced, not the plugin folder, writable, ≥ 1 GB; `D:\TwinCAT-MCP\knowledge` only when the user agrees); `kb_add_project` / `kb_search` over folders the user adds (nothing by default); `hw_lookup` (PDOs, CoE objects from TwinCAT's ESI files); `proj_libraries` (what each library reference resolves to on this PC); known causes/fixes attached to tool errors. Recorder: above 20,000 values/s nothing is decoded on the ingest thread (300 leaves × 1 ms lost samples because decoding starved the ADS callback); the GIL switch interval is 0.5 ms while recording; the missing tail up to the window end is counted (N11); responses trim long arrays; triggers on elements of recorded arrays and `edge_triggers`. `rec_delete` needs a confirmation and the audit keeps the file's SHA-256 (R11). Interfaces are recognised from the project source when the datatype table does not mark them: reads as `0x…`, writes refused (#52/#64). `tc_allow_write` tokens for the pattern as typed (N1); session allow-list kept over `tc_reload_config` (N6); writers scoped to the declaring unit (N3); System Manager refreshed after a solution reopen (N9); env overrides of TOML values reported (N10); external `tcunit_scaffold` not gated (N12); restart `duration_s` until RUN (N5); Check all objects selects the PLC project node and reports the real refusal (#45). 151 tools.
**0.7.1** — decisions of 2026-10-17. The CONSTANT check runs on every write path (`snap_apply`, `test_*`, `exp_*` too); with no project source loaded, guarded mode refuses writes and full mode allows them. Recordings carry a manifest (AppTimestamp, OnlineChangeCnt, tasks and cycle times, TwinCAT build, symbol version) and, in cycle mode, the task's per-cycle counter as ground truth that `rec_verify` uses automatically. `csv_wide` is one row per PLC sample time with array elements as columns; empty cell = not sampled then (`fill=true` for charts). The example config carries the agreed machine-PC settings (`allow_elicitation = false`, `edit_requires_config_mode = true`, tool groups, narrow allow-list). 143 tools.
**0.7.0** — recording accuracy and field report round 3 (A1–A7, #75–79, P2–P19, K1). Recorder: one ADS notification per contiguous memory block instead of per symbol (the field test: 300 values as one block lossless, as 300 notifications ~5 %); the callback only copies the raw sample; file format 2 keeps the runtime's raw bytes and exact PLC FILETIME per sample, a symbol table and block layout in the header, and a signed SHA-256 footer; `missing_samples`/`completeness` per block from the PLC timestamps, `dropped`, `dropped_on_stop`; a stop drains the backlog to the file; duration is PLC time; a notification that cannot be registered fails the start (`allow_partial`); patterns expand to leaves like `rec_scan`; live statistics step aside under load and `rec_stats` recomputes them from the file; new `rec_verify` (integrity, timeline, per-cycle counter) and `rec_capture` (record → stats → verdict in one call); the acceptance run proves a lossless recording of `_TaskInfo[1].CycleCount` at the task cycle. `ads_watch` / `ads_wait_until` on ADS notifications, off the ADS workers; `ads_write_many` as one sum-write (all values in the same PLC cycle); writes report `written` vs `read_back`; symbol/datatype caches follow the runtime's symbol version instead of a 300 s timer; `ads_system_mode` waits for the requested mode; COM calls blocked by a modal dialog fail within a second naming it; `xae_deploy` fails unless the target runs afterwards; `tc_doctor` one-line summary (`verbose` for the config); `server.tool_groups`; launcher skips hashing unchanged sources; Infosys indexes the books of the libraries the project references. Older recording files stay readable. 143 tools.
**0.6.3** — field report round 2 (items 51–74, framework project with inheritance, interfaces, enums, strings, arrays). Writes: an array write must supply every element (a short list zeroed the rest); POINTER/REFERENCE and INTERFACE variables are refused (an interface is a pointer the PLC calls through) and read as `0x…` hex or null; writes to anything declared in a `VAR … CONSTANT` block are refused from the project source (the runtime does not flag them READONLY); STRING(n) length is checked before ADS; enum names are accepted (`Idle`, `E_State.Idle`) in `ads_write` and `test_expect`; `ads_write_many` pre-checks every item before the first write and reports applied/failed/not_attempted if the PLC rejects one. Recorder: `duration_s` no longer compares the PLC's task clock with Windows time (cycle mode ran on for 30 s), `rec_stop` keeps its own stop reason, the file is flushed every second, `post_trigger_s` ends a triggered recording after the trigger. Project: the index refreshes incrementally (mtime) before every index-based tool; `proj_vs_runtime` matches `x[*]` source arrays to their elements, collapses arrays and caps output; `proj_call_graph` follows interface calls to every implementing FB, inherited methods and THIS^ overrides; `proj_interface` merges the EXTENDS chain (`inherited_from`) and inherited IMPLEMENTS; `wf_debug_symbol` no longer calls `fb(x := y)` a write of y and lists .TcVIS input-action writes; `tcunit_scaffold` takes the project's ProductVersion, keeps declared input defaults, includes inherited methods; `empty_method` skips interface/abstract methods; C# enums use the declared base type. Safety: confirmation prompts read `DESTRUCTIVE: <tool> / Target / Machine / Mode / Effect`, the audit records how long the prompt was open and flags sub-1.5 s answers, `safety.allow_elicitation = false` forces the token path, `safety.edit_requires_config_mode = true` refuses code edits unless TwinCAT is in CONFIG; destructive XAE tools refuse via the guard even with no shell attached. IPC: `ipc_eventlog` is confirmed as itself, lists the registered TwinCAT/Beckhoff providers, deletes its temp file; `boot:` resolves to the live 4026 folder (ProgramData), not the stale `C:\TwinCAT\3.1\Boot`. ADS errors 6/7/1797/1808/1861 carry a plain-language hint; TOML config loads on Python 3.10 (tomli). 141 tools.
**0.6.2** — second and third field reports (50 items, same engineering PC). Integer writes are range-checked against the ADS type and BOOL accepts only true/false/0/1 — 70000 into a WORD is refused, not wrapped to 4464; `tc_set_mode` escalation needs a confirmation (lowering stays free); confirmation tokens for XAE destructive actions are bound to the shell's real target at issue time; `ipc_list_dir boot:*` resolves the real boot folder by probing (FFIND ignores path ids) and drops `.`/`..`; `proj_read_file` is confined to the project roots; 4026 PLC command names (`PLC.Loginto<Project>`) are discovered from the shell and the project node is selected when none is active; the nested PLC project node (ItemType 56) is recognised and walked; experiments gain `run.ack: falls|edge|none`, `ack_timeout_ms`, `wait_ms`/`wait_until` steps, `after` as cleanup on failure, `resume=false` archiving earlier runs, `spec_version` on every run; BOOL leaves keep their declared name on 4026 tables; library FB members are expected in `proj_vs_runtime`, counted as instances by `proj_references`, shown as leaf calls in `proj_call_graph`, resolved via the runtime in `proj_resolve_symbol`; `=> x` counts as a write; unqualified type names resolve when unique; confirmed actions audit their final outcome (ok/error); `rec_latency` reports absorbed edges; Infosys DB in WAL mode with serialised indexing; clearer EtherCAT/IO/HMI/AppTimestamp/raw-write messages. Third report: `xae_build check_all_objects=true` discovers the shell's actual command name (`PLC.CheckAllObjects`, localized or 4026-prefixed variants) instead of assuming one; a shell closed and reopened behind the server (RPC_S_SERVER_UNAVAILABLE 0x800706BA) is re-attached automatically on the next call; `proj_lint` reports `root`/`files_indexed`/`parse_errors` and says plainly when `proj_scan` has not run instead of `units: 0`; `eof_newline` removed (XAE never writes one) and `lineids_missing` only fires when a POU is inconsistent (some blocks have LineIds, others not); `hmi_scan` parses TF1800 `.TcVIS` files — `VisualElemMember` paths are read bindings (kind `tcvis`, tagged with the member id name where known), ST input-action assignments are write bindings (kind `tcvis_write`), so `hmi_vs_runtime` covers classic visualizations; `xae_status` attaches to the shell itself. Not done: TwinCAT Scope/Measurement control (`IMeasurementScope` is not IDispatch — needs a .NET host) and visualization *creation* (`.TcVIS` is an opaque XmlArchive; editing it blind would corrupt the file). 141 tools.
**0.6.1** — field fixes from the first real session (Windows 11, TwinCAT 3.1.4026, engineering PC): the launcher no longer blocks the MCP handshake on first start — it builds the venv in a detached background process (exit code 5, reconnect once; a SessionStart hook pre-builds it; `--sync-install` keeps the old behaviour); template placeholders (`<CX_NETID>`, `C:\path\to\…`) and malformed AmsNetIds are dropped with a `config_FIX_THESE` list in `tc_doctor` instead of being loaded; `tc_allow_write` (session allow-list entry, confirmed, audited) and `tc_reload_config` (apply list-type settings without a restart); `xae_restart_twincat` / `xae_activate_configuration` verify the target's mode over ADS and the shell's dialogs and fail with the dialog text instead of reporting success; TwinCAT license errors (0x98110724) mapped to the 7-day-trial remedy; rejected COM calls name the open dialog; a cancelled confirmation prompt is distinguished from a decline; recordings end at their limit immediately and release their notifications from the ingest thread (`rec_status` no longer lags, `rec_export` rows == events, footer reported); Infosys default books index in the background on first use; `python -m twincat_mcp.acceptance` exercises every tool against the live PC/target with a PASS/FAIL/WARN/SKIP/MANUAL report and guard-refusal proofs. 141 tools.
**0.6.0** — experiments (12 tools): YAML spec, background runner with snapshot/profile per run and factor restore, measures from recordings (last/latency/pulses/settle/dwell/edges/expr…), metrics with t/χ² CIs and Cpk, pass/fail/inconclusive verdicts with n-to-settle, Markdown+HTML report with identity/charts/audit/interpretation, compare. Capture profiles (7 tools): `rec_scan` of runtime or source with cost estimates and a markable checklist, `{attribute 'tcmcp.record'}` pragmas collected through `proj_variables` (pragmas now parsed and inherited down instance trees), named profiles replayed as one recording per mode group. `infosys_*` reference lookup (7 tools): sitemap-driven book list, per-library local index with FTS5, symbol search, declarations as data, code-example search, live page → Markdown, sitemap-verified web fallback, TwinCAT 2 filtered out; `scripts/gen_tools_md.py`. Packaging: Claude Code plugin + marketplace in `.claude-plugin/` (`/plugin install twincat-mcp@twincat-mcp`, `userConfig` for Python/AmsNetId/modes), Claude Desktop `.mcpb` bundle (`packaging/mcpb/build.py`, manifest 0.3, tool list generated from the server, passes `mcpb validate`), self-bootstrapping launcher `scripts/launch.py` (+ `launch.cmd`) that creates the venv on first start and reinstalls only when the shipped sources change; skill moved to `skills/twincat-mcp/`; HTTP mode bearer-token gate for claude.ai remote connectors. Plugin path verified end-to-end on Linux (install → venv → `tc_doctor` via `mcp__plugin_twincat-mcp_twincat__tc_doctor`); `launch.cmd` Python discovery is untested on Windows. Windows CI (`.github/workflows/ci.yml`, ubuntu + windows × py3.10/3.12 with a stand-in `TcAdsDll.dll`) found and fixed: `tc_doctor` crashed instead of reporting a dead router; the dialog scan looked at every program's dialogs when no XAE pid was known; a PC without TwinCAT got a ctypes traceback at start instead of the cause. `proj_lint` gains six OOP/safety rules (`interface_contract`, `extends_cycle`, `abstract_instantiation`, `shadowed_member`, `unsigned_loop`, `new_delete`), default-on, zero findings on the sample project. System Service file paths accept OS-independent prefixes (`boot:`, `bootprj:`, `config:`, `user1:`… = ADS path ids); `tcunit_remote_result` defaults to `boot:TcUnit_xUnit_testresults.xml` so the same config serves Windows, CE7 and TC/BSD targets. Refused and declined actions are now audited (`outcome: refused` / `declined-elicitation:*`), not only the ones that ran. `docs/INSTALL.md`: install → config → 14-step zero-write smoke test.
**0.5.0** — dialog watchdog, TwinSAFE guard, XML validation/fixes + LineIds regeneration, TE2000/TcVIS HMI binding index with runtime/source cross-check, I/O disable, variable links, UDP discovery, Windows event log, C#/TypeScript/JSON exporters, snapshot apply, session report, `naming_prefixes` config.
**0.4.0** — composite workflows (`wf_debug_symbol`, `wf_fb_profile`, `wf_release_check`, `wf_project_overview`), MCP prompts as workflow recipes, `rec_compare`, `proj_changed` (git), `proj_scan incremental`, `ads_io_read` (process image), compact symbol listing, size-guarded `ads_read`, separate ADS workers for quick vs long calls, Claude Code integration kit (`integrations/claude-code/`).
**0.3.0** — datatype-table upload and parsing: full nested decode of any variable in one read (`ads_read`), `ads_leaves`, `ads_dump` (every variable on the PLC → JSON + CSV), `ads_types`; recorder prefixes expand through the table (arrays of structs included); `proj_variables` (source-declared variables flattened to instance paths) and `proj_vs_runtime` (source vs online cross-check).
**0.2.0** — real-time recorder (`rec_*`), test bench (`test_*`, `snap_*`), runtime diagnostics (`ads_app_info`, `ads_task_stats`, `ads_ping`), ADS RPC (`ads_call_method`), static checks (`proj_interface`, `proj_lint`, `proj_call_graph`), `tcunit_scaffold`. Review fixes: ROT enumeration (`EnumRunning`), `vsBuildState` constant, raw writes limited to PLC ports with symbol-overlap deny checks, tokens bound to the connected machine, `ads_connect` re-targeting gated, destructive DTE commands classified, project write-back byte-exact (BOM/EOL preserved) and duplicate-name aware, cooperative cancellation of long ADS loops, blocking calls moved off the event loop.
**0.1.0** — initial release.
## Not covered (deliberately)
Scope/TE13xx recording, TE1200 static analysis, NC axis commissioning, Safety (TISC) editing, library repository management, semantic code search, OPC UA. The tree XML tools (`xae_tree_get` / `xae_tree_consume_xml`) reach most of the rest of the System Manager generically.
## Development
```
python -m pytest # 159 tests (safety matrix, type codec, project parser, fake ADS incl. notifications/RPC, fake COM, recorder, bench, lint, MCP e2e incl. elicitation, packaging)
python packaging/mcpb/build.py # dist/twincat-mcp-<version>.mcpb; npx @anthropic-ai/mcpb validate runs if Node is present
claude plugin validate . # plugin + marketplace manifests
python -m twincat_mcp --print-config
python -m twincat_mcp --http 8765 # streamable-http on loopback; --token / TCMCP_HTTP_TOKEN adds a bearer gate for tunnels
```
MIT.
TDQS
Scored across 171 tools
The domain prefixes help, but there are many near-duplicate entry points for capturing or inspecting PLC behavior: rec_start, rec_capture, ads_watch, test_scenario, wf_fb_profile, and exp_run all overlap in purpose. Similarly, several read/write, status, search, and deploy tools have boundaries that are hard to keep straight at 171 tools.
All names are snake_case and use stable family prefixes such as xae_, ads_, proj_, and rec_, but verb/object ordering is mixed: xae_pou_get versus xae_pou_create, ads_list_symbols versus ads_read, and proj_get_code versus proj_scan. It is readable, but not one predictable convention.
171 tools is far beyond a well-scoped MCP surface, even for a broad TwinCAT automation domain. Many workflow helpers duplicate compositions of lower-level tools, and the sheer count overwhelms selection.
Coverage is unusually broad across shell automation, build/diagnostics, PLC runtime ADS, project files, recordings, tests, experiments, documentation, ESI hardware lookup, HMI, IPC, Scope, and safety. Some lifecycle operations are only reachable through generic escape hatches such as xae_command or tree XML, but no major domain gap is apparent.