simconnect-mcp 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. simconnect_mcp-1.0.0/.github/workflows/ci.yml +40 -0
  2. simconnect_mcp-1.0.0/.github/workflows/release.yml +113 -0
  3. simconnect_mcp-1.0.0/.gitignore +9 -0
  4. simconnect_mcp-1.0.0/CLAUDE.md +275 -0
  5. simconnect_mcp-1.0.0/LICENSE.txt +662 -0
  6. simconnect_mcp-1.0.0/PKG-INFO +464 -0
  7. simconnect_mcp-1.0.0/README.md +437 -0
  8. simconnect_mcp-1.0.0/docs/extending-catalogs.md +258 -0
  9. simconnect_mcp-1.0.0/docs/superpowers/plans/2026-03-26-pmdg-client-data.md +1537 -0
  10. simconnect_mcp-1.0.0/docs/superpowers/plans/2026-08-29-mcp-modernization-phase0-correctness.md +3202 -0
  11. simconnect_mcp-1.0.0/docs/superpowers/plans/2026-08-29-mcp-modernization-phase1-mcp-surface.md +1971 -0
  12. simconnect_mcp-1.0.0/docs/superpowers/plans/2026-08-29-mcp-modernization-phase2-capability.md +1860 -0
  13. simconnect_mcp-1.0.0/docs/superpowers/plans/2026-08-31-pypi-publishing.md +1187 -0
  14. simconnect_mcp-1.0.0/docs/superpowers/specs/2026-03-26-pmdg-client-data-design.md +121 -0
  15. simconnect_mcp-1.0.0/docs/superpowers/specs/2026-08-29-mcp-modernization-design.md +327 -0
  16. simconnect_mcp-1.0.0/pyproject.toml +84 -0
  17. simconnect_mcp-1.0.0/scripts/parse_pmdg_sdk.py +1046 -0
  18. simconnect_mcp-1.0.0/scripts/set_version.py +77 -0
  19. simconnect_mcp-1.0.0/server.json +22 -0
  20. simconnect_mcp-1.0.0/src/simconnect_mcp/__init__.py +10 -0
  21. simconnect_mcp-1.0.0/src/simconnect_mcp/__main__.py +5 -0
  22. simconnect_mcp-1.0.0/src/simconnect_mcp/connection.py +620 -0
  23. simconnect_mcp-1.0.0/src/simconnect_mcp/data/__init__.py +1 -0
  24. simconnect_mcp-1.0.0/src/simconnect_mcp/data/catalog.py +240 -0
  25. simconnect_mcp-1.0.0/src/simconnect_mcp/data/hubhop.py +647 -0
  26. simconnect_mcp-1.0.0/src/simconnect_mcp/data/pmdg_737.json +22835 -0
  27. simconnect_mcp-1.0.0/src/simconnect_mcp/data/pmdg_777.json +20669 -0
  28. simconnect_mcp-1.0.0/src/simconnect_mcp/data/simvar_catalog.py +162 -0
  29. simconnect_mcp-1.0.0/src/simconnect_mcp/data/simvars_catalog.json +6538 -0
  30. simconnect_mcp-1.0.0/src/simconnect_mcp/dispatch.py +427 -0
  31. simconnect_mcp-1.0.0/src/simconnect_mcp/docs/best_practices.md +118 -0
  32. simconnect_mcp-1.0.0/src/simconnect_mcp/docs/events.md +119 -0
  33. simconnect_mcp-1.0.0/src/simconnect_mcp/docs/lvars.md +102 -0
  34. simconnect_mcp-1.0.0/src/simconnect_mcp/docs/overview.md +65 -0
  35. simconnect_mcp-1.0.0/src/simconnect_mcp/docs/pmdg_737.md +89 -0
  36. simconnect_mcp-1.0.0/src/simconnect_mcp/docs/pmdg_777.md +492 -0
  37. simconnect_mcp-1.0.0/src/simconnect_mcp/docs/rpn.md +150 -0
  38. simconnect_mcp-1.0.0/src/simconnect_mcp/docs/simvars.md +124 -0
  39. simconnect_mcp-1.0.0/src/simconnect_mcp/facilities.py +305 -0
  40. simconnect_mcp-1.0.0/src/simconnect_mcp/pmdg.py +908 -0
  41. simconnect_mcp-1.0.0/src/simconnect_mcp/pmdg_detect.py +194 -0
  42. simconnect_mcp-1.0.0/src/simconnect_mcp/pmdg_ng3.py +907 -0
  43. simconnect_mcp-1.0.0/src/simconnect_mcp/prompts/__init__.py +1 -0
  44. simconnect_mcp-1.0.0/src/simconnect_mcp/prompts/templates.py +191 -0
  45. simconnect_mcp-1.0.0/src/simconnect_mcp/resources/__init__.py +1 -0
  46. simconnect_mcp-1.0.0/src/simconnect_mcp/resources/documentation.py +119 -0
  47. simconnect_mcp-1.0.0/src/simconnect_mcp/resources/state.py +57 -0
  48. simconnect_mcp-1.0.0/src/simconnect_mcp/server.py +264 -0
  49. simconnect_mcp-1.0.0/src/simconnect_mcp/simvar_access.py +622 -0
  50. simconnect_mcp-1.0.0/src/simconnect_mcp/tools/__init__.py +130 -0
  51. simconnect_mcp-1.0.0/src/simconnect_mcp/tools/aircraft.py +164 -0
  52. simconnect_mcp-1.0.0/src/simconnect_mcp/tools/connection_tools.py +59 -0
  53. simconnect_mcp-1.0.0/src/simconnect_mcp/tools/events.py +541 -0
  54. simconnect_mcp-1.0.0/src/simconnect_mcp/tools/facilities.py +444 -0
  55. simconnect_mcp-1.0.0/src/simconnect_mcp/tools/flight.py +602 -0
  56. simconnect_mcp-1.0.0/src/simconnect_mcp/tools/formatting.py +118 -0
  57. simconnect_mcp-1.0.0/src/simconnect_mcp/tools/hubhop.py +294 -0
  58. simconnect_mcp-1.0.0/src/simconnect_mcp/tools/lvars.py +897 -0
  59. simconnect_mcp-1.0.0/src/simconnect_mcp/tools/models.py +482 -0
  60. simconnect_mcp-1.0.0/src/simconnect_mcp/tools/pmdg.py +533 -0
  61. simconnect_mcp-1.0.0/src/simconnect_mcp/tools/simvars.py +455 -0
  62. simconnect_mcp-1.0.0/src/simconnect_mcp/tools/utilities.py +294 -0
  63. simconnect_mcp-1.0.0/src/simconnect_mcp/vendor/MOBIFLIGHT_LICENSE +21 -0
  64. simconnect_mcp-1.0.0/src/simconnect_mcp/vendor/__init__.py +6 -0
  65. simconnect_mcp-1.0.0/src/simconnect_mcp/vendor/mobiflight_variable_requests.py +204 -0
  66. simconnect_mcp-1.0.0/src/simconnect_mcp/vendor/simconnect_mobiflight.py +39 -0
  67. simconnect_mcp-1.0.0/tests/conftest.py +218 -0
  68. simconnect_mcp-1.0.0/tests/fixtures/facilities/airport_list.hex +1 -0
  69. simconnect_mcp-1.0.0/tests/fixtures/facilities/ndb_list.hex +1 -0
  70. simconnect_mcp-1.0.0/tests/fixtures/facilities/vor_list.hex +1 -0
  71. simconnect_mcp-1.0.0/tests/fixtures/facilities/waypoint_list.hex +1 -0
  72. simconnect_mcp-1.0.0/tests/live/__init__.py +0 -0
  73. simconnect_mcp-1.0.0/tests/live/conftest.py +154 -0
  74. simconnect_mcp-1.0.0/tests/live/test_live_events.py +115 -0
  75. simconnect_mcp-1.0.0/tests/live/test_live_flight.py +175 -0
  76. simconnect_mcp-1.0.0/tests/live/test_live_lvars.py +231 -0
  77. simconnect_mcp-1.0.0/tests/live/test_live_mobiflight_responses.py +115 -0
  78. simconnect_mcp-1.0.0/tests/live/test_live_pmdg.py +112 -0
  79. simconnect_mcp-1.0.0/tests/live/test_live_simvars.py +138 -0
  80. simconnect_mcp-1.0.0/tests/live/test_pmdg_gate.py +66 -0
  81. simconnect_mcp-1.0.0/tests/test_aircraft.py +138 -0
  82. simconnect_mcp-1.0.0/tests/test_connection.py +128 -0
  83. simconnect_mcp-1.0.0/tests/test_connection_tools.py +121 -0
  84. simconnect_mcp-1.0.0/tests/test_dispatch.py +689 -0
  85. simconnect_mcp-1.0.0/tests/test_documentation.py +298 -0
  86. simconnect_mcp-1.0.0/tests/test_error_suggestions.py +85 -0
  87. simconnect_mcp-1.0.0/tests/test_events.py +390 -0
  88. simconnect_mcp-1.0.0/tests/test_facilities_parsing.py +587 -0
  89. simconnect_mcp-1.0.0/tests/test_facilities_tools.py +610 -0
  90. simconnect_mcp-1.0.0/tests/test_flight.py +629 -0
  91. simconnect_mcp-1.0.0/tests/test_formatting.py +77 -0
  92. simconnect_mcp-1.0.0/tests/test_hubhop.py +367 -0
  93. simconnect_mcp-1.0.0/tests/test_hubhop_tool.py +300 -0
  94. simconnect_mcp-1.0.0/tests/test_logging.py +102 -0
  95. simconnect_mcp-1.0.0/tests/test_lvar_listing.py +529 -0
  96. simconnect_mcp-1.0.0/tests/test_lvars.py +371 -0
  97. simconnect_mcp-1.0.0/tests/test_mobiflight_responses.py +78 -0
  98. simconnect_mcp-1.0.0/tests/test_mobiflight_unavailable_code.py +90 -0
  99. simconnect_mcp-1.0.0/tests/test_models.py +51 -0
  100. simconnect_mcp-1.0.0/tests/test_packaging.py +100 -0
  101. simconnect_mcp-1.0.0/tests/test_pmdg.py +911 -0
  102. simconnect_mcp-1.0.0/tests/test_pmdg_ng3.py +569 -0
  103. simconnect_mcp-1.0.0/tests/test_registration.py +375 -0
  104. simconnect_mcp-1.0.0/tests/test_search.py +210 -0
  105. simconnect_mcp-1.0.0/tests/test_set_version.py +94 -0
  106. simconnect_mcp-1.0.0/tests/test_simvar_access.py +1072 -0
  107. simconnect_mcp-1.0.0/tests/test_simvar_catalog.py +108 -0
  108. simconnect_mcp-1.0.0/tests/test_simvars.py +570 -0
  109. simconnect_mcp-1.0.0/tests/test_title_detection.py +893 -0
  110. simconnect_mcp-1.0.0/tests/test_tools_init.py +214 -0
  111. simconnect_mcp-1.0.0/tests/test_utilities.py +295 -0
  112. simconnect_mcp-1.0.0/uv.lock +1007 -0
@@ -0,0 +1,40 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ test:
11
+ name: Tests (Python ${{ matrix.python-version }})
12
+ # Windows is required, not preferred: dispatch.py and facilities.py import
13
+ # ctypes.wintypes at module level, which does not import on Linux.
14
+ runs-on: windows-latest
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ # Floor from requires-python, plus a current release.
19
+ python-version: ["3.10", "3.13"]
20
+
21
+ steps:
22
+ - name: Checkout
23
+ uses: actions/checkout@v7
24
+
25
+ - name: Install uv
26
+ uses: astral-sh/setup-uv@v10.0.1
27
+ with:
28
+ python-version: ${{ matrix.python-version }}
29
+ enable-cache: true
30
+
31
+ - name: Install dependencies
32
+ run: uv sync --locked
33
+
34
+ - name: Lint
35
+ run: uv run ruff check src tests scripts/set_version.py
36
+
37
+ - name: Run mocked test suite
38
+ # pyproject sets addopts = "-m 'not live'", so the 28 live tests that
39
+ # need a running MSFS are deselected here.
40
+ run: uv run pytest -q
@@ -0,0 +1,113 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ test:
9
+ name: Tests (Python ${{ matrix.python-version }})
10
+ runs-on: windows-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ python-version: ["3.10", "3.13"]
15
+
16
+ steps:
17
+ - name: Checkout
18
+ uses: actions/checkout@v7
19
+
20
+ - name: Install uv
21
+ uses: astral-sh/setup-uv@v10.0.1
22
+ with:
23
+ python-version: ${{ matrix.python-version }}
24
+ enable-cache: true
25
+
26
+ - name: Install dependencies
27
+ run: uv sync --locked
28
+
29
+ - name: Lint
30
+ run: uv run ruff check src tests scripts/set_version.py
31
+
32
+ - name: Run mocked test suite
33
+ run: uv run pytest -q
34
+
35
+ publish:
36
+ name: Publish to PyPI and the MCP Registry
37
+ needs: test
38
+ runs-on: ubuntu-latest
39
+ permissions:
40
+ # PyPI trusted publishing and `mcp-publisher login github-oidc` both
41
+ # mint short-lived OIDC tokens; neither uses a stored secret.
42
+ id-token: write
43
+ contents: read
44
+
45
+ steps:
46
+ - name: Checkout the tagged commit
47
+ uses: actions/checkout@v7
48
+
49
+ - name: Install uv
50
+ uses: astral-sh/setup-uv@v10.0.1
51
+ with:
52
+ enable-cache: true
53
+
54
+ - name: Derive the version from the tag
55
+ run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"
56
+
57
+ - name: Set the version in pyproject.toml and server.json
58
+ run: python scripts/set_version.py "$VERSION"
59
+
60
+ - name: Build the distributions
61
+ # Pure-Python py3-none-any wheel, so building on Linux is fine even
62
+ # though the package only runs on Windows.
63
+ run: uv build
64
+
65
+ - name: Publish to PyPI
66
+ run: uv publish --trusted-publishing always
67
+
68
+ - name: Install mcp-publisher
69
+ run: |
70
+ curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
71
+
72
+ - name: Authenticate to the MCP Registry
73
+ run: ./mcp-publisher login github-oidc
74
+
75
+ - name: Publish to the MCP Registry
76
+ run: ./mcp-publisher publish
77
+
78
+ sync-version:
79
+ name: Commit the version bump back to main
80
+ needs: publish
81
+ runs-on: ubuntu-latest
82
+ permissions:
83
+ contents: write
84
+
85
+ steps:
86
+ - name: Checkout main
87
+ uses: actions/checkout@v7
88
+ with:
89
+ ref: main
90
+
91
+ - name: Install uv
92
+ uses: astral-sh/setup-uv@v10.0.1
93
+
94
+ - name: Derive the version from the tag
95
+ run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"
96
+
97
+ - name: Set the version in pyproject.toml and server.json
98
+ run: python scripts/set_version.py "$VERSION"
99
+
100
+ - name: Sync the lockfile
101
+ run: uv lock
102
+
103
+ - name: Commit and push
104
+ run: |
105
+ git config user.name "github-actions[bot]"
106
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
107
+ git add pyproject.toml server.json uv.lock
108
+ if git diff --staged --quiet; then
109
+ echo "main is already at $VERSION; nothing to commit"
110
+ else
111
+ git commit -m "chore: set version to $VERSION"
112
+ git push origin HEAD:main
113
+ fi
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ .venv/
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ .ruff_cache/
9
+ .pytest_cache/
@@ -0,0 +1,275 @@
1
+ # CLAUDE.md
2
+
3
+ ## Project Overview
4
+
5
+ SimConnect MCP Server — an MCP server that gives AI agents full read/write access to Microsoft Flight Simulator via SimConnect. Built for the add-on development use case, not consumer flight assistance.
6
+
7
+ ## Architecture
8
+
9
+ ```
10
+ MCP Protocol (FastMCP, stdio)
11
+ → server.py (tool/resource/prompt registration, lifespan)
12
+ → tools/*.py (domain modules)
13
+ → connection.py (SimConnectManager singleton)
14
+ → simvar_access.py (SimVarAccessor — generic SimVar reads/writes, replaces AircraftRequests)
15
+ → facilities.py (facility *_LIST message parsing/accumulation, feeds tools/facilities.py)
16
+ → dispatch.py (SimConnectDispatcher — owns the dispatch loop; both of the above depend on it)
17
+ → vendored SimConnectMobiFlight (client-data support for the WASM bridge)
18
+ → SimConnect DLL / MSFS
19
+ → MobiFlight WASM Module (L-vars, calculator code)
20
+ ```
21
+
22
+ ### Key Design Decisions
23
+
24
+ - **Singleton connection** — SimConnect allows one connection per process. `SimConnectManager` is a thread-safe singleton with lazy-connect.
25
+ - **`run_in_executor` + `threading.Lock`** — all SimConnect DLL calls are blocking and not thread-safe. Every call goes through `run_sync()` which acquires a lock and runs in an executor to avoid blocking the async MCP event loop.
26
+ - **MobiFlight optional** — the vendored `SimConnectMobiFlight` (from [Koseng/MSFSPythonSimConnectMobiFlightExtension](https://github.com/Koseng/MSFSPythonSimConnectMobiFlightExtension)) is a drop-in subclass of `SimConnect` that adds client-data support for the WASM bridge. If it fails to load, core SimVar/event tools still work; only L-var tools degrade.
27
+ - **`SimVarAccessor` replaced `AircraftRequests`** — SimVar access no longer goes through `SimConnect.AircraftRequests`, which holds a hardcoded table of ~828 variables each bound to one fixed unit and signals failure by returning `None`/`False`. `simvar_access.py`'s `SimVarAccessor` builds data definitions directly (`AddToDataDefinition` + `RequestDataOnSimObject`/`SetDataOnSimObject`), which is what makes unit selection, reading variables outside the table, string variables, and honest write failures possible. `SimConnectManager.set_lvar` routes through the same accessor rather than a hand-rolled copy of the pattern.
28
+ - **`SimConnectDispatcher` owns `my_dispatch_proc`.** This is the single most important invariant in the codebase. `dispatch.py`'s `SimConnectDispatcher` (a subclass of the vendored `SimConnectMobiFlight`) takes over the SimConnect dispatch loop so SimVar reads/writes and exceptions can be correlated back to the call that caused them, and so facility (`*_LIST`) messages can be parsed directly. **Anything added to the dispatch loop must never fall through to the library's `SYSTEM_STATE` or `*_LIST` branches** — both call `print()` (`handle_state_event`, and `dump()` on the facility objects) and would corrupt the JSON-RPC stream this server speaks over stdio.
29
+ - **The vendored bridge has exactly two documented local changes** — see the header comment in `vendor/mobiflight_variable_requests.py`: per-call logging demoted from INFO to DEBUG, and response-channel strings (definition ID 0) routed to registered handlers instead of being logged as "DefinitionID not found" and dropped. Both must survive a re-sync from upstream — the header comment is the only thing standing between a re-sync and silently reverting them. `vendor/simconnect_mobiflight.py` carries no local changes at all.
30
+ - **Native L-var writes** — `SimConnectManager.set_lvar()` (which the `msfs_set_lvar` tool calls) uses `AddToDataDefinition` + `SetDataOnSimObject` (the native SimConnect API, via `SimVarAccessor`), NOT the MobiFlight RPN `set()` command. This is critical because proprietary aircraft like the Fenix ignore MobiFlight RPN writes but respond to native SimConnect data writes.
31
+ - **`clear_sim_variables()` on connect** — the MobiFlight WASM module retains stale variable registrations from prior sessions. Without clearing on connect, all reads return 0.
32
+ - **Tool naming and contract** — every tool is registered in `server.py` with the `msfs_` prefix and explicit `ToolAnnotations` (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `title`); there is no annotation-free registration path. Every tool function returns `SomeResult | ToolError`, never a bare dict and never a fabricated success — a new tool must follow both conventions.
33
+
34
+ ## Project Structure
35
+
36
+ ```
37
+ src/simconnect_mcp/
38
+ ├── server.py # FastMCP instance, lifespan, tool registration
39
+ ├── connection.py # SimConnectManager singleton + native set_lvar
40
+ ├── dispatch.py # SimConnectDispatcher — owns the SimConnect dispatch loop
41
+ ├── simvar_access.py # SimVarAccessor — generic SimVar reads/writes via data definitions
42
+ ├── facilities.py # Facility (*_LIST) message parsing and accumulation
43
+ ├── pmdg.py # PMDG 777 SDK structs, CDU rendering, data manager
44
+ ├── pmdg_ng3.py # PMDG 737 NG3 SDK structs, CDU rendering, data manager
45
+ ├── pmdg_detect.py # PMDG variant detection/probe, shared by tools/pmdg.py and tools/lvars.py
46
+ ├── tools/
47
+ │ ├── __init__.py # @handle_simconnect_errors, @require_connection decorators
48
+ │ ├── connection_tools.py # connect_to_sim, disconnect_from_sim, get_connection_status
49
+ │ ├── simvars.py # SimVar CRUD via SimVarAccessor (1,080+ vars in the catalog)
50
+ │ ├── events.py # Event trigger/search + built-in catalog
51
+ │ ├── lvars.py # L-var read/write/search/enumerate/browse catalogs/calculator code
52
+ │ ├── pmdg.py # PMDG tools — auto-dispatch to 777 or 737 NG3
53
+ │ ├── aircraft.py # get_aircraft_snapshot — combined state snapshot
54
+ │ ├── facilities.py # Airport/navaid lookup on top of facilities.py
55
+ │ ├── flight.py # load/save flight, load flight plan, spawn AI object
56
+ │ ├── hubhop.py # HubHop preset search, exposed as MCP tools
57
+ │ ├── utilities.py # send_sim_text, set_aircraft_position
58
+ │ ├── models.py # Shared Pydantic result/error models
59
+ │ └── formatting.py # Pagination and markdown-table helpers
60
+ ├── resources/
61
+ │ ├── documentation.py # Embedded docs served as MCP resources
62
+ │ └── state.py # Live connection/aircraft state resources
63
+ ├── prompts/
64
+ │ └── templates.py # debug_simvar, analyze_aircraft_vars, rpn_helper, etc.
65
+ ├── data/
66
+ │ ├── catalog.py # L-var catalog loader and search engine
67
+ │ ├── simvar_catalog.py # SimVar catalog loader, unit resolution, string-var detection
68
+ │ ├── hubhop.py # MobiFlight HubHop API client (CLI + library), in-memory cache
69
+ │ ├── pmdg_777.json # PMDG 777 catalog (1,607 vars, 28 panels)
70
+ │ ├── pmdg_737.json # PMDG 737 NG3 catalog (1,861 vars, 27 panels)
71
+ │ └── simvars_catalog.json # Built-in SimVar catalog (1,080+ vars, 25 categories)
72
+ ├── vendor/ # Byte-faithful to upstream except two documented local changes
73
+ │ ├── simconnect_mobiflight.py # unmodified
74
+ │ └── mobiflight_variable_requests.py # log-level demotion + response-channel routing
75
+ └── docs/ # Embedded markdown documentation, served as MCP resources
76
+ ├── overview.md, simvars.md, events.md, rpn.md, lvars.md, best_practices.md
77
+ └── pmdg_777.md, pmdg_737.md
78
+ ```
79
+
80
+ ## Known Sim Behaviours
81
+
82
+ Measured against a live sim during this project. Each cost real investigation to establish, so they are recorded here rather than left to be rediscovered. This is a reference, not a narrative.
83
+
84
+ - **MSFS 2024 packs facility records on the wire.** The installed `SimConnect.Enum` bindings for `SIMCONNECT_DATA_FACILITY_AIRPORT/WAYPOINT/NDB/VOR` are stale in two independent ways: they declare a single `Icao[9]` field where MSFS 2024 actually sends `Ident[6]` + `Region[3]`, and they assume ordinary 8-byte-aligned structs where the wire records are packed with no padding. `facilities.py` defines its own structs with `_pack_ = 1` and the Ident/Region split; verified strides are AIRPORT 33, WAYPOINT 37, NDB 41, VOR 77. Committed fixtures in `tests/fixtures/facilities/` replay real captured bytes, so a regression back to 8-byte alignment fails the suite immediately.
85
+ - **Only the airport facility list is world-wide** (85,249 records, unrelated to the aircraft's position). Waypoints, NDBs and VORs are a "reality bubble" scoped to wherever the aircraft currently is — all measured within ~193 nm of it — so they must not be cached across a reposition. Only AIRPORT is cached (`SimConnectManager._facility_cache`); the other three are recollected on every call.
86
+ - **`MF.LVars.List` is capped at 1000 names and still sends its end sentinel**, so a truncated response is indistinguishable from a complete one at the protocol level. `msfs_list_lvars` reports `truncated: true` when the raw pre-filter wire count hits the cap. Treat any one source — a catalog or a live listing — as a starting point, not a guaranteed inventory: `msfs_get_lvar` reads any name you supply, whether or not it surfaced there.
87
+ - **The WASM module ignores a repeated identical command.** Sending `MF.LVars.List` twice in a row returns nothing the second time. A trailing space does not help — this is not a byte-level dedupe check — and the state survives reconnection, so it lives in the WASM module itself, not the client. The fix that works reliably: send a different, zero-side-effect RPN command (a bare `MF.SimVars.Set.1` literal, which stores nothing) immediately before `MF.LVars.List` to re-arm it. See the call site in `tools/lvars.py` before "simplifying" this away.
88
+ - **PMDG aircraft ignore default key events.** A `(>K:PARKING_BRAKES)` that does nothing on a loaded PMDG aircraft is the aircraft's own behaviour — PMDG reimplements most default events internally rather than responding to them — not a broken mechanism. Use `msfs_send_pmdg_event` for PMDG control surfaces instead of `msfs_trigger_event`.
89
+
90
+ - **Saving a flight freezes SimConnect for several seconds.** `FlightSave` writes its `.FLT` almost immediately (~0.13s for a 69 KB file) and returns `S_OK`, but MSFS then stops answering SimConnect requests entirely while it finishes — measured from 0.7s up to 14.5s on the same aircraft and session, with no identified cause for the variance. During that window every read fails with a timeout whose message blames a paused or loading sim, which is wrong. `msfs_save_flight` therefore polls until the sim answers again before returning, so its contract is "when this returns, the sim is usable"; `msfs_load_flight` and `msfs_load_flight_plan` use the same wait (a load's own stall measured only ~0.9s). Do not remove that wait, and do not "fix" it by reconnecting — the connection is fine, the sim is busy.
91
+
92
+ - **PMDG event parameters are not always the documented switch positions.** `EVT_OH_ELEC_BATTERY_SWITCH` with `parameter=1` behaves as a *toggle* (0 → 1, then 1 → 0); `parameter=0`, `2` and no parameter all do nothing, even though the catalog's `values` map for `ELEC_BatSelector` reads `{"0": "OFF 1", "2": "ON"}` (itself visibly malformed). Guarded switches also need their guard lifted first. When a PMDG event appears not to work, read the field back rather than trusting the catalog's value map — the tool reports the send honestly as unconfirmed precisely because the sim gives no acknowledgement.
93
+
94
+ ## Extending the Aircraft L-Var Catalog
95
+
96
+ The catalog system provides searchable, human-readable L-var databases per aircraft. When `msfs_search_lvars("seatbelt")` is called, the server auto-detects the loaded aircraft in two steps: PMDG's own client-data-area probe first (`pmdg_detect.detect_or_probe_pmdg_catalog` — the same authoritative probe `msfs_get_pmdg_var`/`msfs_get_pmdg_cdu` use, confirming which SDK is actually loaded regardless of what `TITLE`/`ATC_MODEL` say), then every catalog's own `title_pattern` matched against `TITLE`/`ATC_MODEL` as the fallback. The fallback is the *only* mechanism available to a third-party catalog dropped into `data/` (e.g. a regenerated Fenix catalog — see "Fenix A320/A321 Notes" below), since the probe only knows about PMDG. Both `msfs_search_lvars` and `msfs_browse_lvar_catalog` report which of the two resolved the catalog in `message`, so a caller can tell a live-confirmed detection apart from a plain text match — and when neither finds anything, the search spans every catalog and says so rather than guessing.
97
+
98
+ A real, live-verified gap this two-step order fixes: a PMDG 737-800's `TITLE` can read `'737-800 PAX SSW TC'` — no "PMDG" substring at all — which fails every bundled catalog's `title_pattern` on its own. Before the probe was added to catalog auto-detection, `msfs_search_lvars` on that aircraft silently searched every bundled catalog (all PMDG) instead of scoping to the one actually loaded, with only an easy-to-miss footer note distinguishing the two.
99
+
100
+ ### Adding a New Aircraft
101
+
102
+ Create a JSON file in `src/simconnect_mcp/data/` (e.g., `fbw_a320.json`). All `*.json` files in this directory are auto-discovered on startup.
103
+
104
+ ```json
105
+ {
106
+ "aircraft": "FlyByWire A320neo",
107
+ "title_pattern": "FlyByWire",
108
+ "variables": [
109
+ {
110
+ "name": "A32NX_AUTOPILOT_1_ACTIVE",
111
+ "display_name": "Autopilot 1 Active",
112
+ "category": "Autopilot",
113
+ "prefix": "A32NX",
114
+ "writable": true,
115
+ "values": {"0": "Off", "1": "On"}
116
+ }
117
+ ],
118
+ "panels": {
119
+ "Autopilot": [
120
+ "A32NX_AUTOPILOT_1_ACTIVE",
121
+ "A32NX_AUTOPILOT_2_ACTIVE"
122
+ ]
123
+ }
124
+ }
125
+ ```
126
+
127
+ ### Field Reference
128
+
129
+ | Field | Required | Description |
130
+ |-------|----------|-------------|
131
+ | `aircraft` | Yes | Human-readable aircraft name |
132
+ | `title_pattern` | Yes | Substring matched against `TITLE` SimVar for auto-detection (case-insensitive) |
133
+ | `variables` | Yes | Array of variable definitions |
134
+ | `variables[].name` | Yes | L-var name (without `L:` prefix) |
135
+ | `variables[].display_name` | Yes | Human-readable name for search and display |
136
+ | `variables[].category` | No | System/panel category for grouping |
137
+ | `variables[].prefix` | No | Variable prefix (for filtering) |
138
+ | `variables[].writable` | No | Whether the variable can be written (default: false) |
139
+ | `variables[].values` | No | Map of numeric values to descriptions (e.g., `{"0": "Off", "1": "On"}`) |
140
+ | `panels` | No | Groups variable names by physical panel/section |
141
+
142
+ ### Tips for Building Catalogs
143
+
144
+ - **From existing tools:** export variable lists from FSUIPC, MobiFlight Connector, or SPAD.neXt
145
+ - **From source code:** if the aircraft is open source (e.g., FlyByWire), extract variable names from the codebase
146
+ - **By discovery:** use `msfs_list_lvars` to enumerate variables on a loaded aircraft (capped at 1000 names — see "Known Sim Behaviours" above), then categorize by prefix patterns
147
+ - **The `title_pattern`** should match a unique substring from `msfs_get_simvar("TITLE")`
148
+
149
+ ## Fenix A320/A321 Notes
150
+
151
+ The Fenix uses a proprietary internal system with specific patterns you need to know when interacting with it.
152
+
153
+ **No catalog is bundled for the Fenix.** `data/fenix_a320.json` (1,433 plain L-vars, no SDK struct fields) was removed once HubHop's own `FenixSim` coverage overtook it (2,273 presets, community-maintained, current the moment Fenix ships an update). Search it live with `msfs_search_hubhop(vendor="FenixSim")`, or regenerate a local catalog file with `data/hubhop.py`'s CLI --
154
+ `python -m simconnect_mcp.data.hubhop --vendor FenixSim --aircraft-name "Fenix A320/A321" --title-pattern Fenix -o fenix_a320.json` -- and drop it into `src/simconnect_mcp/data/`; every `*.json` there is auto-discovered on startup, so it starts working immediately with no code change. The prefix/FCU/button knowledge below still applies regardless of whether a catalog is loaded, since it describes the sim's behaviour, not catalog data.
155
+
156
+ ### Variable Prefix Convention
157
+
158
+ | Prefix | Type | Writable | Description |
159
+ |--------|------|----------|-------------|
160
+ | `S_` | Switch | Yes | Toggle switches, selector positions |
161
+ | `A_` | Analog | Yes | Rotary knobs with numeric positions |
162
+ | `E_` | Event counter | Yes | Rotary encoders (increment/decrement by changing value) |
163
+ | `N_` | Numeric | No | Display readouts, computed values |
164
+ | `I_` | Indicator | No | Status lights, switch position indicators |
165
+ | `B_` | Boolean | No | On/off indicator lights |
166
+
167
+ ### Counter-Based FCU Controls
168
+
169
+ Fenix FCU controls (altitude, heading, speed, V/S) do **NOT** accept direct value writes. They use a counter-based pattern:
170
+
171
+ 1. Read the current counter value from the `E_` variable (e.g., `E_FCU_ALTITUDE`)
172
+ 2. Read the current display value from the `N_` variable (e.g., `N_FCU_ALTITUDE`)
173
+ 3. Calculate the number of steps needed
174
+ 4. For altitude: set `S_FCU_ALTITUDE_SCALE` to `0` (force 100ft mode) first
175
+ 5. Increment/decrement the `E_` counter by 1 for each step, with 15ms delay between steps
176
+ 6. Restore the scale mode after
177
+
178
+ Each change of ±1 in the counter triggers one knob click in the sim. The Fenix detects the **direction of change**, not the absolute value.
179
+
180
+ ### Button Transitions
181
+
182
+ Some Fenix switches are momentary buttons that need a press-release cycle: set to 1, wait 200ms, set to 0.
183
+
184
+ ### Write Method
185
+
186
+ The Fenix responds to native SimConnect `SetDataOnSimObject` for L-var writes, NOT to MobiFlight RPN `set()` commands. The `msfs_set_lvar` tool already uses the native method.
187
+
188
+ ## Running Tests
189
+
190
+ ```bash
191
+ uv run pytest # all tests (mocked; no MSFS required)
192
+ uv run pytest -v # verbose
193
+ uv run pytest -k search # only search tests
194
+ uv run pytest -m live # live suite against a real, running MSFS instance
195
+ ```
196
+
197
+ Tests mock SimConnect so the default run happens without MSFS. The `conftest.py` fixture provides a mock SimConnect with realistic SimVar values.
198
+
199
+ ### What the live suite is for
200
+
201
+ `tests/live/` is deliberately small, and stays that way by one rule: **a
202
+ live test earns its place only if a self-consistent mock could agree with
203
+ itself regardless of whether the code is right.** A round trip on one L-var
204
+ name proves nothing about encoding, because a mangled datum name would make
205
+ the write and the read-back agree with each other on the wrong variable —
206
+ see `test_two_distinct_lvars_do_not_collide` in `test_live_lvars.py` for the
207
+ test that actually closes that gap, and its docstring for the full
208
+ reasoning. Anything a mock *can* settle — pure Python logic, cache
209
+ bookkeeping, message wording, string matching against a catalog — belongs
210
+ in the mocked suite instead, however tempting it is to also check it live.
211
+
212
+ What's left after that filter is a short, specific list: does the real DLL's
213
+ own unit conversion match the physical constant we assume; does a real
214
+ SimConnect wire reply decode correctly through structs this project has
215
+ already caught the installed package mis-declaring once (`RecvException` in
216
+ `dispatch.py`); does a real airframe's calculated variables actually reject
217
+ a write (or silently ignore it, which is the bug this layer replaces); does
218
+ the real MobiFlight WASM module's undocumented quirks (repeated-command
219
+ drop, definition-ID-0 routing) behave the way the workaround assumes; and
220
+ whether a real PMDG's binary client-data area answers a probe the way its
221
+ struct decode expects. None of that can be established by a mock inventing
222
+ its own answer and checking it against itself.
223
+
224
+ Prefer freezing a live finding into a committed fixture over adding another
225
+ live test. `tests/fixtures/facilities/` holds real SimConnect wire bytes
226
+ captured once from a live session, replayed by the *mocked*
227
+ `tests/test_facilities_parsing.py` on every run, with no simulator and no
228
+ flakiness — strictly better than a live test for anything it can cover,
229
+ because it pins the exact discovery deterministically instead of
230
+ re-discovering it (or failing to) on whatever aircraft happens to be loaded
231
+ that day. Reach for that pattern first; reach for `tests/live/` only for the
232
+ residue no fixture can freeze because the claim is about behaviour, not
233
+ about a fixed byte layout.
234
+
235
+ `tests/live/test_live_pmdg.py`'s tests need a real PMDG 737/777 loaded and
236
+ skip -- rather than fail -- when the loaded aircraft doesn't look like one
237
+ (see that file's gate in `conftest.py`); a connection failure skips the
238
+ whole suite the same way.
239
+
240
+ The live suite under `tests/live/` is marked `@pytest.mark.live` and deselected by default (`pyproject.toml`'s `addopts = "-m 'not live'"`), so it never runs on a machine without MSFS — including CI. It requires MSFS running with an aircraft loaded; a connection failure is skipped rather than failed, but some files assume a specific aircraft is loaded (check that file's own module docstring before running it against an arbitrary airframe).
241
+
242
+ ## Releasing
243
+
244
+ Pushing a `v*` tag is the whole release. `.github/workflows/release.yml` runs
245
+ the mocked suite on Windows, rewrites the version from the tag (minus its `v`)
246
+ into `pyproject.toml` and `server.json` via `scripts/set_version.py`, builds,
247
+ publishes to PyPI, publishes to the MCP Registry, and commits the bump back to
248
+ `main` in a separate job.
249
+
250
+ ```bash
251
+ git tag v0.3.0
252
+ git push origin v0.3.0
253
+ ```
254
+
255
+ Notes for anyone changing this:
256
+
257
+ - **Do not hand-edit the version in more than one place.** `pyproject.toml` is
258
+ authoritative; `src/simconnect_mcp/__init__.py` resolves its own version from
259
+ installed metadata, and `server.json` is rewritten by the release. Tests in
260
+ `tests/test_packaging.py` fail if these drift apart.
261
+ - **`scripts/set_version.py` is unit-tested and pins its writes to LF** so it
262
+ produces identical bytes on the Linux runner and a Windows checkout. It is
263
+ covered by CI's lint step for the same reason: a bug there ships a wrong
264
+ version number to PyPI.
265
+ - **Both publish steps authenticate via OIDC, not stored tokens** — hence
266
+ `id-token: write` on the `publish` job. PyPI additionally requires a trusted
267
+ publisher registered once through the PyPI web UI (project `simconnect-mcp`,
268
+ owner `robin24`, repo `simconnect-mcp`, workflow `release.yml`, no
269
+ environment). Without it, `uv publish` fails with an authentication error.
270
+ - **The registry verifies PyPI ownership by finding `mcp-name:
271
+ io.github.robin24/simconnect-mcp` in the package description**, which is
272
+ `README.md`. Removing that marker from the README breaks registry publishing
273
+ even though nothing else notices.
274
+ - **CI must stay on `windows-latest`** — `dispatch.py` and `facilities.py`
275
+ import `ctypes.wintypes` at module level, which does not import on Linux.