Skip to content

veaf-mission-mcp — LLM-assisted mission-editing MCP server

Audience: developers evolving the mission-editing MCP server, or wiring an MCP client (Claude Code, an agent) to it.

🇫🇷 mission-editing-mcp.md.

🎯 Mission-Maker side (plain-language catalogue): mission-maker/AI_ASSISTANT_CATALOG.en.md.

Why this server

First phase of NL-MISSION-GEN (see ROADMAP.md §4): let an LLM edit a DCS mission on a Mission Maker's behalf — and, eventually, generate one end-to-end from a detailed prompt. See ADR 0014 for the architecture decision, and CONTEXT.md (section "LLM-assisted mission editing") for the vocabulary.

Two action families, deliberately kept apart:

  • Editor-parity action — mutates the mission's raw .miz Lua tables directly, exactly the way a Mission Maker would by hand in the DCS Mission Editor (add a group, a trigger, a zone). Never goes through mission.yaml. This is the entire scope of this server in v1.
  • VMCT action — goes through the existing declarative mission.yaml pipeline (inject_presets, aircraft_groups...). As of wave 4 the server exposes a first brick of this family: editing the source mission.yaml (see below), alongside the usual CLI/config path.

Running the server locally

poetry install
poetry run veaf-mission-mcp   # in dev
# or, from the shipped binary (what `veaf-tools ai-setup` declares in the AI clients):
veaf-tools mcp

Starts an MCP server over stdio (the mcp SDK's default transport). No configuration: every action receives the .miz path to edit as a parameter. The veaf-tools mcp subcommand embeds the server in the already-shipped veaf-tools binary (no separate binary to build) — this is what veaf-tools ai-setup declares in Claude Desktop, Claude Code, Codex, Antigravity, Gemini CLI and OpenCode.

Started from the shipped binary, the server also updates itself: in a background thread, at most once every 4 hours, only when check_updates is on and no pre-release is pinned (VEAF_MCP_UPDATER_TAG), it compares its version with the published-latest release and, when that is newer, starts veaf-tools-updater in its own folder, in a hidden console (veaf_mission_mcp/self_update.py). The updater sets the running veaf-tools.exe aside rather than overwriting it, which Windows refuses; the new version is used from the client's next start.

Action catalog (v1)

The mission is mission_path, everywhere (FIX-OPEN-TRAINING-PROMPT-FINDINGS, ticket 05)

42 of the 47 actions took the mission under five names: miz_path (17), target (9), folder_path (6), mission_path (4) and mission_yaml_path (6). The catalog now publishes mission_path (a mission folder or a .miz) for all of them and translates it back to the key the handler reads: the three former names are still accepted as aliases, and the examples below that use them stay valid. mission_yaml_path is kept, since it names another file; a mission_path given in its place (a folder) is read as <folder>/mission.yaml. A catalog test fails when a new action introduces a sixth name.

miz_path also takes a mission folder (FIX-MCP-AUTHORING-GAPS lot, ticket 03)

Every editing action — edit_route, set_group_properties, set_unit_properties, edit_zone, add_trigger_zone, add_map_drawing, edit_map_drawing — takes either a .miz or a mission folder, exactly like add_group's target. The trade-off is the same: a folder edit is durable (it goes into src/mission/, so it survives the next veaf-tools build), a .miz edit is transient (the next build overwrites it). Each action now returns durable to say which. Timestamped backup either way.

They used to take only a .miz, so a group could be created durably but not edited durably, and pointing edit_route at the folder failed with [Errno 13] Permission denied — reading a directory as a zip archive. A directory that is not a mission folder is now refused with a message that says so.

The server does not expose one MCP tool per business action. It exposes a fixed discovery surface, mirroring the dcs-bridge MCP tool (a bridge to a running mission):

MCP tool Role
capabilities() Server identity (name, version).
list_catalog(full=False) List registered actions as {name, summary} — the description's first sentence, about 10 kB for all of them; full=true gives every description and parameters_schema (some 70 kB, which a client saves to a file rather than shows). describe_action gives one in full.
describe_action(name) Detail one action's parameter JSON Schema.
run_action(name, params) Run a registered action.

Concrete actions are registered by veaf_mission_mcp.actions.register_default_actions (src/python/veaf-tools/veaf_mission_mcp/actions.py).

describe_mission

Read-only. Lists the groups (name, coalition, country, category) and trigger zones (name, position, radius) already present in the .miz — so the caller can check current state before writing, the same way a human would check the editor's outliner before adding something. Reuses the existing pure-Python parser (mission_tools.miz_tools.read_miz) — no new parsing.

{"miz_path": "path/to/mission.miz"}

describe_units (FEAT-MCP-MUTATION-ACTIONS lot)

Read-only. The level of detail describe_mission does not give: each group's units (type, skill, livery, callsign, side number, position, heading, altitude, fuel, counters/gun), their loadout, and the group's route with the tasks at each waypoint.

Three shape decisions, each for a reason measured on a real mission (Foothold Caucasus 4.4.1, 357 armed units):

  • pylons is keyed by pylon number, never positional. DCS numbers stations and the numbers are not contiguous: a real FA-18C carries pylons 1, 4, 5, 6 and 9. In that mission 170 of 357 units have a gapped layout, and the Lua parser hands those back as a dict while it flattens the contiguous ones into a list. A reader treating pylons as an ordered list would therefore be right about half the time and silently wrong the rest — which is how a future setter comes to hang a weapon on the wrong station.
  • The editor's automatic tasks are flagged and stripped. A waypoint task is a ComboTask mixing the task the author added with the options the editor writes by itself (ROE, radar usage, formation), all marked auto = true: 1093 automatic entries against 189 authored ones in that mission. Both are reported — hiding them would misrepresent the mission — but only authored tasks carry their params.
  • A cap the caller is told about. The whole mission is 1.9 MB of JSON and a single 62-waypoint group is 18 KB. Hence the filters (group_name by fragment, coalition, category), the default limit of 50 groups with truncated/matched in the answer, and include_route: false, which omits the key rather than returning an empty list ("not asked for" is not "this group has no route").

Booleans come back as booleans: DCS omits a key that is false, and a caller reading null cannot tell "off" from "the reader did not look".

{"miz_path": "path/to/mission.miz", "group_name": "Colt", "include_route": false}

set_unit_properties (FEAT-MCP-MUTATION-ACTIONS lot)

Since FIX-SCRATCH-MISSION-FINDINGS ticket 19 it also renames (new_name, refused when another unit already has the name: DCS wants them unique across the mission) and moves (position) a single unit; the group's anchor and route stay put. For an aircraft, whose place is tied to the route, a warning says so.

Write. The first action that changes an object the mission already contains: every set_* shipped before it acts on configuration (modules, security, logging, an airbase's coalition). Timestamped backup before the write, like its siblings.

It addresses the unit by exact group name and exact unit name — not by fragment, unlike describe_units: a fragment makes the edit land on whichever group matched first, which is not recoverable. A name that misses lists what exists, so a caller can retry without re-reading the whole mission.

Three shapes were measured on real missions rather than inferred, and two contradict the ticket that asked for them:

  • skill has seven values, not four. Average, Good, High, Excellent and Random are AI levels; Client and Player are human slots. Crossing that line one way adds a place to the multiplayer list, the other way removes one — the bug FIX-TEMPLATE-SLOTS-VISIBLE was opened for. Both directions are therefore refused naming the reason, instead of being honoured as a skill setting.
  • An aircraft's callsign is not a plain field. It is a table {1: family, 2: flight, 3: number, name: "Colt11"} where name is the family's word followed by the two indices ({1:1, 2:1, 3:2} reads Enfield12). Writing name alone desynchronises what DCS says on the radio from what the editor shows, so the action edits the indices and rebuilds name from the prefix already there. Changing the family requires DCS's family→word table, which this repository does not ship: that is refused unless the caller supplies the resulting name.
  • heading is radians while a mission maker speaks degrees — the trap resolve_coordinates hides elsewhere. The parameter is named heading_deg so the unit cannot be mistaken, and the value is normalised onto one turn (−90 is 270). On an airborne aircraft (plane/helicopter category, a route of 2+ waypoints, an in-air first waypoint) the action warns: DCS recomputes the heading from the route's first leg on save, so a set heading has a lifetime of one save (FIX-MCP-EDITOR-ROUNDTRIP, measured 2026-08-15). To point an airborne aircraft, set the route, not the heading. The heading is still written — the warning informs, it does not refuse; a parked aircraft or a ground unit does not trigger it.

What the action does not validate, for want of the data to do it: a weapon's CLSID against the airframe carrying it, and a livery against the skins installed. DCS silently drops an impossible weapon and silently shows the default skin, so both limits are returned as warnings rather than implied by their absence.

pylons is keyed by station number, never positional, for the reason measured in describe_units. No pylons means "leave the loadout alone"; {} in replace mode means "carry nothing"; in merge mode an empty CLSID empties that station.

{
  "miz_path": "path/to/mission.miz",
  "group_name": "Colt 1-1",
  "unit_name": "Colt 1-1-1",
  "skill": "Excellent",
  "heading_deg": 270,
  "pylons": {"4": ""},
  "pylons_mode": "merge"
}

The result carries changed, giving each touched field its previous value and the new one: a caller that cannot say what it replaced cannot undo it.

set_group_properties (FEAT-MCP-MUTATION-ACTIONS lot)

Write. Acts on the whole group: move, rename, frequency, modulation, and the three booleans (lateActivation, hidden, uncontrolled). Timestamped backup before the write.

The move carries the whole design of this module, and it is not "write x and y". A group is units in a formation plus possibly a route. The translation therefore applies to every unit, every waypoint and the group's own x/y anchor, by a single vector: otherwise the formation shears, or the route detaches from the units it belongs to — and neither shows before somebody flies the mission. The shear test (move the units, leave the waypoints) is written to fail on any implementation that forgets it, and that was verified by deliberately breaking the translation.

The vector comes from the geodesic offset of FEAT-GEO-PLACEMENT (ADR 0015), not from adding metres to x: a DCS theatre is the real world projected, so "5 km east" is a lat/lon question. A theatre with no projection makes the bearing + distance form refuse, pointing at move_to instead.

frequency_mhz is checked against the airframe's HumanRadio, reusing the presets injector's validator rather than re-deriving it: FIX-PRIMARY-FREQ-HUMANRADIO established that the DCS editor refuses to save a mission whose primary frequency falls outside that range. Every unit type in the group is checked, not just the first — a mixed group would otherwise pass on its first member and be refused by the editor because of another.

The rename runs the reserved-convention check (validate_group_name) and refuses by default: a group renamed onto a combat zone's trigger-zone name is despawned at start, silently. Renaming into a convention is a legitimate intent, hence acknowledge_conventions — what matters is that it be deliberate. Unit names never follow: they carry markers of their own (#command=, #veafInterpreter[...]) that a cascade would rewrite blind.

What the action cannot do, measured rather than overlooked: check the surface at the destination. There is no terrain data on the Python side — land.getSurfaceType is a runtime API and only its schema ships here — which is exactly why FEAT-SCENERY-AWARE-SPAWN solved the problem at runtime. So a move warns that it could not look, instead of validating and lying.

{
  "miz_path": "path/to/mission.miz",
  "group_name": "Red SAM Battery",
  "move_bearing": 90,
  "move_distance_m": 5000,
  "late_activation": true
}

edit_route (FEAT-MCP-MUTATION-ACTIONS lot)

Write. Two layers: the route (add, insert, remove, reorder, set) is mostly a list operation on route.points; a waypoint's tasks (add_task, clear_tasks) are what makes a flight do something.

On or off the road. On a ground group, road: true writes a Turning Point's On Road action (the vehicles follow the roads), road: false writes Off Road; for add, insert and set. Refused on any other group and any other waypoint type. Every point used to be written Turning Point, and the demo mission's convoy drove across fields (FIX-DEMO-MISSION-FINDINGS 07).

A convoy's route at creation. A ground group made by add_group or create_combat_zone with two points or more starts on the road (On Road, like its legs) and runs at 16.4 m/s (59 km/h); a point may give its own speed, in m/s. Measured 2026-10-13 over the 126 vehicle groups of 14 hand-built missions whose route has an On Road leg: first point On Road in 85, Off Road in 37; the legs' speed has a median of 16.4 m/s, 5.6 m/s in only 11 % (FIX-OPEN-TRAINING-FALKLANDS-FINDINGS ticket 04). A single point stays Off Road, a ship keeps its Turning Point and its speed. That the start stands on a road is checked by nothing outside the game (ticket 06).

The invariant that makes this surgery rather than list editing. FIX-WAYPOINTS-ETA-LOCKED established that DCS refuses to save a mission whose route has no waypoint with a locked time ("Route has no waypoints with locked time!"), and that its own repair is to lock the first. Removing or reordering can therefore produce a mission the editor rejects, far from the edit that caused it. Every operation restores the invariant and says so when it had to.

Units. The mission table holds metres and metres per second; a mission maker speaks feet and knots. As with set_unit_properties' heading_deg, the parameters carry their unit in their name (altitude_ft, speed_kt) and the result reports both, so a caller never converts back.

Tasks are a named set with validated signatures, not a free-form table — a deliberate choice from the ticket: a generic "write this task table" action is a foot-gun, because an agent produces a plausible table, DCS ignores it silently, and the mission maker finds out an hour later. The escape hatch starts closed.

Every signature was read out of a real mission, and three are traps:

  • SetFrequency takes hertz (31000000 for 31 MHz) while a group's frequency — set_group_properties — is in MHz. Two units for the same notion, in the same file. The action takes MHz and converts.
  • EngageTargetsInZone duplicates its target list into a serialised value string ("Air;Cruise missiles;") beside the targetTypes array; writing only the array leaves the mission carrying two versions of the same decision.
  • SetFrequency and SwitchWaypoint are not tasks but actions, carried inside a WrappedAction envelope. Written as a bare task, DCS ignores it.

Two measured details the ticket did not mention: a waypoint's type and action are a pair ("Land" goes with "Landing"), and an added waypoint inherits its neighbour's altitude and speed — otherwise it is written at altitude 0 and the flight dives into the ground to reach it — unless altitude_ft/speed_kt are given to add/insert, which are then written (FIX-MCP-EDITOR- ROUNDTRIP: they were accepted and then silently dropped, the inheritance overwriting the asked-for value).

Attack tasks must carry the full field set the editor keeps. FIX-MCP-EDITOR-ROUNDTRIP measured (2026-08-15) that a Bombing written without weaponType is discarded by the editor on save — a strike package that drops nothing. Bombing and AttackGroup therefore now carry weaponType (measured "Auto" default: 2032 for Bombing, 9659482112 for AttackGroup, overridable via weapon_type), the altitude/altitudeEnabled and direction/directionEnabled pairs present but disabled by default (enabled when the caller passes altitude_ft/direction_deg), and the expend/attackQty/groupAttack set. EngageTargetsInZone also carries noTargetTypes (its exclusion list, empty by default).

Task order matters. DCS runs tasks by number, and a task placed after an orbit that never ends is never reached. add_task appends by default; task_position (1-based) inserts it at a given place and renumbers the others — to put an engagement before the orbit (FIX-SCRATCH-MISSION-FINDINGS ticket 17).

{
  "miz_path": "path/to/mission.miz",
  "group_name": "Colt 1-1",
  "operation": "add_task",
  "index": 2,
  "task": "orbit",
  "task_params": {"pattern": "Race-Track", "altitude_ft": 20000, "speed_kt": 300}
}

edit_zone (FEAT-MCP-MUTATION-ACTIONS lot)

Write. add_trigger_zone only creates circular zones and nothing edited one afterwards, so adjusting a VEAF combat zone — which is a trigger zone — meant deleting it and building it again.

Two measurements before any code, as the ticket required:

  • A polygon zone's real shape, read out of veaf-demo-mission.miz (czBatumi): type: 2 plus a verticies list — DCS's own spelling, kept verbatim because correcting the typo would write a field DCS ignores — while x, y and radius stay present. A polygon is therefore not a circle with extra fields.
  • What the VEAF runtime handles. At the time, veafCombatZone.lua branched on exactly two types: 0 → mist.getUnitsInZones, 2 → mist.getUnitsInPolygon(triggerZone.verticies). There was no else, so a zone of any other type would contain no units, in silence — worse than not offering the shape. The action therefore writes only 0 and 2. Since DROP-MIST, the branch lives in veaf.getUnitsInTriggerZone and calls VEAF's own veaf.getUnitsInCircularZone / veaf.getUnitsInPolygon; it still accepts only 0 and 2, but an unexpected type now logs an error and returns nil instead of an empty list, so the silence is gone. The action's rule is unchanged.

David's call on the vertex count (2026-08-12): accept three or more, since "follow the ridge line" is the real use case and mist handles an arbitrary polygon — but warn whenever the count is not four, the DCS editor having no tool to draw or reshape a non-quad zone. The open question of whether it preserves one was settled in game on 2026-08-15 (FIX-MCP-EDITOR-ROUNDTRIP): a 6-vertex zone came back unchanged through a save, so the action does not refuse above four; the warning states a known limitation (you cannot edit the shape by hand there), not an unknown risk.

Two refusals the ticket left open, decided here: a link to a unit that does not exist is refused rather than warned (a zone linked to nothing simply never follows anything, silently), and a name collision is refused (zones are referenced by name from mission.yaml).

{
  "miz_path": "path/to/mission.miz",
  "zone_name": "czBatumi",
  "vertices": [
    {"x": -359753.0, "y": 614918.0},
    {"x": -355602.0, "y": 622688.0},
    {"x": -352849.0, "y": 617192.0},
    {"x": -358731.0, "y": 614282.0}
  ]
}

add_map_drawing / edit_map_drawing (FEAT-MCP-MUTATION-ACTIONS lot)

Write. Nothing in VMCT touched F10 map drawings, so a briefing line, an ingress corridor or a no-fly box was drawn by hand in the editor — and vanished the moment the mission was rebuilt from its folder. That is the whole argument: a drawing an agent places is part of the recipe, a hand-drawn one is not.

The measurement that governs the design, read out of this repository's fixtures:

points are relative to the drawing's mapX/mapY anchor, the first one being {0, 0}.

A drawing written in absolute coordinates lands hundreds of kilometres away and nothing errors — the same class of silent failure as confusing the mission table's {x=north, y=east} with a runtime vec3 (see docs/agents/dcs-coordinates.md). So the actions take the absolute coordinates a caller actually has and do the anchoring themselves. The payoff shows in edit_map_drawing: moving a drawing is moving its anchor, and the shape follows for free.

Six shapes ship because six shapes were measured: Line (with lineMode segment or segments, and closed for a shape that joins up), Polygon in rect mode (width/height/angle, no points), TextBox (text/font/fontSize; the font is taken from a real drawing — one absent from DCS renders as nothing), and — added 2026-08-15 from bridge-Syria-editeur.miz (ticket 10) — Polygon in circle mode (radius, no points or angle), oval (r1/r2/angle), and free (points relative to the anchor like a Line, a free-form filled area, three or more points).

arrow and icon were measured but stay refused, with a reason rather than a guess: an arrow stores a computed 8-point outline beside its length/angle, so writing the parameters alone needs an in-game round-trip to learn whether DCS recomputes the outline (its own ticket); an icon needs a file from the editor's icon set (e.g. P91000007.png), which nothing here enumerates, and an unvalidated name renders as nothing. chevron was removed — it is not a DCS editor tool.

Line style (style, FIX-OPEN-TRAINING-CAUCASUS-FINDINGS): one of DCS's sixteen styles — solid (default), solid2, dot, dot2, dotdash, dash, cross, square, strongpoint, triangle, wirefence, boundry1 to boundry5, DCS's spelling included. It applies to every shape but textbox, which refuses it; a name outside the list is refused with the list. Measured in the editor's me_draw_panel.lua: the mission stores the style's name alone, and the editor derives the texture from it on load — an unknown name would make that fail.

The layer is a first-class parameter, never a default: a drawing on the wrong layer is invisible to the pilots who need it and visible to the ones who should not see it.

{
  "miz_path": "path/to/mission.miz",
  "layer": "Blue",
  "shape": "line",
  "name": "FSCL",
  "points": [{"x": -300000.0, "y": 600000.0}, {"x": -290000.0, "y": 610000.0}]
}

add_group

Write. Inserts a ground/vehicle group into the source .miz, in place, with a systematic timestamped backup before the write (mission_tools.miz_backup.backup_before_write, e.g. mission.20260712-143012.miz). A same-second collision is disambiguated (-2, -3, ...), never silently overwritten.

{
  "miz_path": "path/to/mission.miz",
  "coalition": "red",
  "country_id": 0,
  "country_name": "Russia",
  "category": "vehicle",
  "name": "Red Armor Section",
  "position": {"x": 1000.0, "y": 2000.0},
  "units": [{"type": "T-72B", "count": 2}],
  "route": [{"x": 1000.0, "y": 2000.0}, {"x": 1200.0, "y": 2000.0}],
  "patrol": true
}
  • units — the server does no unit-catalog curation: concrete DCS types (T-72B, BTR-80...) are the calling LLM's decision, not this action's. Each unit may carry an explicit name (else auto-named). That is where a combat-zone marker goes (the runtime reads them off the unit name): #command, #spawngroup, #spawnradius, #spawncount, #spawnchance, #spawndelay, #alarm. The classic idiom is a "fake-unit" group whose name is #command="-armor ..." (a list_shortcuts alias): on zone activation it spawns the described group. E.g. units: [{"type": "Soldier M4", "name": "#command=\"-armor, spawnRadius 300\""}].
  • route — optional; defaults to a single stationary point at position. With patrol: true (and at least 2 points), the last point loops back to the first via a GoToWaypoint task — a classic DCS ground-unit patrol.
  • A name already taken is refused, group name and unit name alike, naming the group that holds it. DCS does not refuse two objects of one name: Group.getByName returns either one and says nothing, and everything that names a group (a QRA, a combat zone, remove_group) works on the wrong one. Applies to every action that creates a group (add_air_group, add_player_slot, create_qra…), which share the same insertion.
  • groupId/unitIds are always fresh (mission_tools.group_insertion.max_ids), even on a mission with gaps in its existing id ranges.

Naming intents (wave 6). The caller expresses intent and add_group produces a convention-correct name itself (veaf_mission_mcp.group_naming.resolve_group_name):

  • for_combat_zone: <zone> — prefix the name with the trigger-zone name (combat-zone membership rule), idempotent and case-insensitive;
  • late_activation: true — set the DCS lateActivation flag (QRA interceptors, CAP templates);
  • as_spawn_template: true — veafSpawn- prefix (spawnable-aircraft template).

add_group also returns a warnings field (see validate_group_name): it still writes, but flags any convention collision for the caller to relay.

Clear ground (FEAT-CLEAR-GROUND-AT-AUTHORING lot). When the tool chooses the position, a stationary vehicle group (no route) is put on ground measured clear of trees and buildings, at most 1 km from the asked position, translating the group as one body. The measurement comes from the theatre's clear-ground catalogue, swept once in DCS with veaf-tools dcs clear-ground-sweep (Caucasus ships; otherwise the one swept on this workstation). The room needed is the extent of the units written; for a #command marker, it is the worst case of the group the runtime will draw (computed from veaf-units.yaml, spawn radius included: about 214 m for -sa10). A warnings entry always says what happened: moved by N m, nothing large enough within reach, a place the catalogue does not cover (with the command that sweeps it), or a group whose size is only known when it spawns (-armor, -infantry…), left to the runtime. The group is never refused: failing better, it stays where it was asked to go.

  • keep_position: true — the position is the one the user gave: the group is never moved. Same parameter on the groups of create_combat_zone.

add_player_slot (FIX-SCRATCH-MISSION-PLAYABLE lot)

Write. Creates a player slot — a flyable aircraft group — which add_group (ground) cannot, and without which a from-scratch mission is not flyable at all. Timestamped backup before writing. Targets a folder (durable) or a .miz (transient).

{
  "target": "path/to/mission-folder",
  "coalition": "blue",
  "country_id": 2,
  "country_name": "USA",
  "name": "Player Viper",
  "unit_type": "F-16C_50",
  "position": {"x": 1000.0, "y": 2000.0},
  "start": "ground-cold",
  "parking": "43",
  "parking_id": "16",
  "airdrome_id": 24
}
  • skill: Client — the multiplayer-slot skill, playable in single-player too. This action does not change an existing unit's skill: set_unit_properties refuses Client/Player and this is not a back door to it.
  • dynSpawnTemplate is set to false. That flag marks a dynamic-spawn template, which needs an airfield configured for it; left on (as on a copy of a template) the slot sits in the file but does not appear in the slot list — the defect found in game on 2026-08-14.
  • start — "air" (position + altitude_ft + speed_kt + heading_deg, no runtime data; DCS orients an air start toward its second waypoint, so the slot gets one 10 km ahead on heading_deg — with a single point it flew north whatever the heading, FIX-AIR-SLOT-HEADING), "ground-cold" or "ground-hot". A ground start requires parking, parking_id and airdrome_id; without them it is refused (the message names the data captured by FEAT-MCP-MUTATION-ACTIONS ticket 09), never guessed. The first waypoint's type/action pair is written per mode.
  • frequency_mhz is written (group radio on) rather than inherited from a communication: false.
  • Full internal fuel by default. fuel (kg) and fuel_fraction (]0, 1]) let you choose the load; without them the aircraft gets its type's internal capacity, read from the shipped units database (dcsUnits.yaml, the fuel_capacity field sourced from the datamine's M_fuel_max). Both actions wrote fuel = 0 until 6.15.1, which means no fuel at all: measured in game on 2026-08-18, a KC-135 and its two F-15C escorts created at 20 000 ft pitched straight into the ground on appearing, engines out. A parking start hid the defect, DCS fuelling a parked aircraft from the airfield's stock. A type the database does not know — a third-party mod — is created with no fuel key (leaving DCS its own default) and the caller is warned, rather than handed an invented number.
  • Assigns the country to its side in coalitions (see add_group), so the mission stays loadable.

add_air_group (FEAT-MCP-MUTATION-ACTIONS lot, ticket 09)

Write. Puts a flight (one or more aircraft) on the ramp, resolving the stands itself from an airfield name — the "a two-ship of F-16s at Incirlik" case that add_player_slot (one aircraft, caller supplies the spot) does not cover. Timestamped backup before writing. Targets a folder (durable) or a .miz (transient).

{
  "target": "path/to/mission-folder",
  "coalition": "blue", "country_id": 2, "country_name": "USA",
  "name": "Viper", "unit_type": "F-16C_50", "count": 2,
  "start": "parking-cold", "airfield": "Kobuleti"
}
  • Stand resolution. The airfield name is resolved to an id (veaf_libs.dcs_airdromes), then to free stands via the slimmed bundled capture (veaf_libs.dcs_parking, generated by veaf-build update-dcs-data --parking). It takes count free stands, nearest to the runway first, and seats each aircraft at the stand's exact position.
  • parking_id = parking. Settled in game 2026-08-15: parking is the capture's Term_Index, the aircraft seats from the exact position, and the editor's own parking_id — absent from the capture — is not load-bearing, so it is written equal to parking.
  • Only terminal types 68, 72 and 104 are offered as parking — DCS's own FighterAircraft mask (244). 100 (SmallSizeFighter) is excluded on purpose: DCS documents it as a tight spot for small airframes, and it unlocks no airfield the other three do not already cover. An airfield with none of the three is refused rather than seating an aircraft on a runway threshold or a helipad. The measurement behind the set, and the seven airfields it unlocked, are recorded beside AIRCRAFT_STAND_TYPES in veaf_libs/dcs_parking.py.
  • Collision refused. A stand already occupied in the mission (an aircraft group whose first waypoint targets this airdrome and one of whose units declares that stand) is refused naming the group that holds it; auto-selection skips occupied stands.
  • Starts. parking-cold / parking-hot (need airfield), runway (needs airfield, anchored on the field, no stand consumed), air (needs position). The first waypoint's type/action pair and its ETA_locked are written for you.
  • skill defaults to an AI level (High) — a ramp flight is AI unless you ask for Client/Player. parking accepts an explicit stand list that overrides selection.
  • Full internal fuel by default. fuel (kg) and fuel_fraction (]0, 1]) let you choose the load; without them the aircraft gets its type's internal capacity, read from the shipped units database (dcsUnits.yaml, the fuel_capacity field sourced from the datamine's M_fuel_max). Both actions wrote fuel = 0 until 6.15.1, which means no fuel at all: measured in game on 2026-08-18, a KC-135 and its two F-15C escorts created at 20 000 ft pitched straight into the ground on appearing, engines out. A parking start hid the defect, DCS fuelling a parked aircraft from the airfield's stock. A type the database does not know — a third-party mod — is created with no fuel key (leaving DCS its own default) and the caller is warned, rather than handed an invented number.
  • An uncaptured theatre, an unknown airfield, or too few free stands are refused naming the cause. Assigns the country to its side in coalitions.

remove_group (FIX-MCP-AUTHORING-GAPS lot, ticket 02)

Write. Removes a group from the mission — the one group-level edit the catalogue was missing, while edit_zone and edit_map_drawing both have a remove: true. Targets a folder (durable) or a .miz (transient), timestamped backup before writing.

{"target": "path/to/mission-folder", "group_name": "Texaco"}
  • Renumbers what it leaves behind. That is the whole point: hand-deleting a Lua block leaves the enclosing list numbered 1,3,4, which Lua loads without complaint and the build dies on (AttributeError: 'int' object has no attribute 'get') at a line that does not point at the edit. Three corrupted builds on 2026-08-18 came from exactly that — and the stopgap of the day, a renumbering regex keyed on indentation alone, also renumbered units and route.points. The survivors keep their order and are re-keyed from 1.
  • The last group of a category takes the group key with it, rather than leaving an empty container — the shape a downstream reader mistakes for a list (FIX-GROUP-CONTAINER-SHAPE).
  • Exact name required. A fragment is refused, as set_group_properties refuses one: a removal landing on whichever group matched first is not recoverable. An unknown name lists what exists, and nothing is written.
  • A name several groups share is refused, listing their groupIds: group_id says which one goes, the other stays. It used to remove all of them and report one.
  • Names what it breaks without refusing — the mission maker may well mean it: a combat zone capturing the group by name prefix, an Escort task pointing at its groupId (nested inside a ComboTask, which is how DCS actually writes it), and a mission.yaml modules.ASSETS.assets entry naming it. That last one is only checkable on a folder target: a .miz carries no mission.yaml.

validate_group_name (wave 6)

Read-only. Checks a proposed name against the reserved patterns (veafSpawn-/OnDemand-/ VEAF-placeholder- prefixes, #veafInterpreter[...]/#command= markers, QRA deploy syntax, fixed CAS names) and, with miz_path, the combat-zone capture trap (name starting with an existing trigger-zone). expected_combat_zone suppresses the warning for the intended zone. Shares the veaf_mission_mcp.group_naming module with add_group.

{"name": "combatZone_North-tanks", "miz_path": "path/to/mission.miz"}

add_trigger_zone (wave 2)

Write. Inserts a named circular trigger zone into mission.triggers.zones, with a fresh zoneId, in place and backed up first. This is the zone a VEAF combat zone references: combined with add_group, it lets you lay down a full combat zone (the trigger zone group_validation requires + the groups inside it). No deduplication.

{
  "miz_path": "path/to/mission.miz",
  "name": "combatZone_North",
  "position": {"x": 1000.0, "y": 2000.0},
  "radius": 3000,
  "hidden": false
}

add_startup_script_trigger (wave 2)

Write. Adds a "mission start" trigger that runs a script — for outfitting a vanilla or CTLD mission with scripting without the DCS editor's Triggers tab. Generalizes inject_dcs_bridge_trigger and the VEAF static/dynamic loading mechanism (ADR 0004). Unlike that helper (which inserts at index 1 and renumbers everything), this appends at the next free index — no existing trigger is renumbered. Three modes:

  • inline — run supplied Lua (inline_lua) via a_do_script.
  • file_static — embed a .lua file (source_path) into the .miz (l10n/DEFAULT/<name>.lua + mapResource entry) and load it via a_do_script_file.
  • file_dynamic — load a .lua from a runtime disk path (runtime_path) via loadfile, nothing embedded.
{
  "miz_path": "path/to/mission.miz",
  "mode": "file_static",
  "comment": "load my script",
  "source_path": "C:/scripts/myscript.lua"
}

Timestamped backup before the write; no deduplication.

Editing embedded Lua files (wave 3)

Third action family: edit the text of the .lua files embedded in the .miz (l10n/DEFAULT/**/*.lua), without a rebuild — neither the raw mission.lua tables (editor-parity) nor the mission.yaml pipeline (VMCT action). Shared brick: mission_tools.rewrite_miz_members copies the archive through verbatim and swaps only the targeted members (no Lua-table re-serialization). Timestamped backup before each write.

replace_in_mission_files — generic search-replace

Text or regex replacement, restricted to l10n/DEFAULT/**/*.lua (never mission/ options or binaries). files is a glob matched against each .lua's path relative to l10n/DEFAULT/.

{
  "miz_path": "path/to/mission.miz",
  "search": "debug",
  "replace": "info",
  "files": "veaf-*.lua",
  "regex": false
}

Returns {files_changed, total_replacements}.

VMCT settings (veaf-config.lua)

Semantic actions editing l10n/DEFAULT/veaf-config.lua (the build-generated VEAF config). Each replaces the line if present, else inserts it at the top (before the modules initialise):

  • set_log_level(level) → veaf.ForcedLogLevel = "<level>" (error/warning/info/debug/trace).
  • set_module_enabled(module_id, enabled) → veaf.setConfig("<MOD>", "enable", <bool>).
  • set_security_disabled(disabled) → veaf.SecurityDisabled = <bool>.
  • set_veaf_config(key, value) → veaf.config.<key> = <Lua scalar>.

Airfield coalition

  • set_airbase_coalition(folder_path, name, coalition, dynamic_spawn=True) — durably assign a DCS airfield to a coalition, in a mission folder; dynamic_spawn=False keeps its dynamic slots closed (an enemy base).

⚠️ An airfield's coalition lives in warehouses.airports[<id>].coalition, not in mission.coalition. Placing a unit near a base therefore never turns the base itself — this action is what does. It resolves the airfield name to an id through the mission's theatre, sets the coalition, and turns on the base's Dynamic Spawn slots (the build then stocks them) unless dynamic_spawn is false. Backed up first, like the other editing actions.

dynamic_spawn: false also records the base under <side>.exclude_airports in src/warehouses.yaml, and true removes it: the build opened every base of a declared side with no airports: list, dynamicSpawn = false or not (FIX-SCRATCH-MISSION-FINDINGS ticket 15). Nothing is written when the file is absent (the step does not run) or the side is not declared in it (declaring it would open every one of its bases).

Multi-mission campaign

  • campaign_status(campaign_folder) — where the campaign stands: missions applied, each zone's owner, garrison strength, kind and neighbours, reserves, objectives met or not, the last mission's changes. Read-only.
  • campaign_apply(campaign_folder, state_file) — merges a flown mission's state file, plays the turn between missions, judges the objectives, writes and returns the FR/EN debriefing; refuses a file already applied, from another campaign or skipping a mission, writing nothing.
  • campaign_next(campaign_folder, players?) — creates (a copy of template/) or refreshes missions/mission-NN/mission: airfields to their owner through set_airbase_coalition, src/campaign-data.yaml, the CAMPAIGN module in mission.yaml, and, on a folder it creates, the strategic situation as the briefing and the date, time and weather fixed in the mission (one mission, no variant; the day after the last mission, a solar time computed on the campaign's ground, a weather drawn with the ground visible); writes the strategic briefing deck next to it, and the mission briefing when the mission is built; returns the folder, conditions, the decks and the factual part of the strategic briefing in FR and EN. Its description asks Claude to set the date and time from the campaign's progress and to keep the ground visible, and to ask how many players are expected tonight: players (a count, or a range "5-7", beating campaign.yaml's players) writes the mission's opposition: block.
  • campaign_briefing(campaign_folder) — writes missions/mission-NN/briefing-campagne.pptx and its strategic map from the campaign (facts) and briefing.yaml (prose); returns the paths, the page count, whether the prose was there, whether the map had to do without tiles, the sections briefing.yaml may hold, and mission_deck: the mission briefing (briefing-mission.pptx, tactical maps), read from the built mission — None and a warning saying how to build while there is no .miz. Its description carries the writing rules: a military situation brief, no game mechanics outside the annex, the enemy kept to uneven intelligence.

Each one is the matching veaf-tools campaign command (campaign_manager.CampaignWorker), returning data instead of printing it. See Multi-mission campaign.

FARP

  • add_farp(target, name, position, coalition, country_id, country_name, farp_type="FARP", frequency_mhz=127.5, modulation="AM", callsign_id=1, ammo_dump=True, keep_position=False) — a complete FARP: the heliport static (category = "Heliports", the type's shape_name), its radio and callsign, and the warehouses.warehouses[<unitId>] entry that lets helicopters refuel there. add_group in static placed the object alone (ticket 19). Shape measured on the 372 heliports of the missions under D:\dev\_VEAF. warehouses.yaml's farps: then stocks it at build, like a base. By default, its ammunition dump (FARP Ammo Dump Coating, <name> - Ammo, 120 m east, Fortifications / SetkaKP like the 68 of the OT missions) that CTLD takes as a loading point; ammo_dump=False skips it (FIX-OPEN-TRAINING-SYRIA-FINDINGS ticket 05). A pad in the sea (ground 0 m on the elevation grid) is warned about (ticket 04). The pad is checked (FIX-FARP-PLACEMENT ticket 01): where the clear-ground catalogue covers the place, a pad in trees or buildings is moved with its dump onto the nearest clear ground, within 1 km (keep_position=True refuses the move and only warns); where the elevation grid covers it, more than 3 m of height difference between the centre and a 40 m ring is warned as a slope. Where neither covers the place, the warning says what was not checked and gives the veaf-tools dcs farp-spot command, which finds a flat, clear spot in DCS; position in the result says where the pad went. Neither the grid nor the catalogue sees a lake or a river (the grid knows the sea only): the result always carries surface_check, which says so and repeats that command (FIX-OPEN-TRAINING-FALKLANDS-FINDINGS ticket 03). A frequency that is an airfield tower's of the theatre, in the same band (AM: vhf and uhf; FM: fm), is warned with the airfield's name. Clearing the trees around the pad by trigger (ticket 02) was tried and taken out: DCS's « SCENERY REMOVE OBJECTS ZONE » removed no tree on MarianaIslands or Caucasus (known limitation scenery-remove-objects-zone-leaves-the-forest).

Carrier group, deck slots, sounds (FIX-OPEN-TRAINING-PROMPT-FINDINGS)

  • add_carrier_group(mission_path, coalition, country_id, country_name, name, position, heading_deg, speed_kt, carrier_type="Stennis", carrier_name, escorts, tower_mhz, tacan_channel, tacan_callsign, icls_channel, link4_mhz, recovery_tanker, tanker_*, rescue_helicopter) — the carrier (and its escorts) under way, its radio on the unit (hertz), ActivateBeacon (TACAN, system 3), ActivateICLS, and on an arrested-landing deck ActivateLink4 + ActivateACLS; the <unit> S3B-Tanker (Tanker task + TACAN Y) and <unit> Pedro groups veafCarrierOperations looks for by name; the warehouses.warehouses[<unitId>] entry. The ATC tasks are written on the first route point and in the group's tasks: of 218 groups measured, 120 keep them on the group, 55 on the route, 43 in both, and veafCarrierOperations reads only the group; which of the two DCS runs has not been measured.
  • add_air_group(..., start="deck-cold"|"deck-hot", carrier=<ship unit>) — first point linked to the ship (linkUnit = helipadId = its unitId), deck spots numbered on; refused when the aircraft cannot both take off from and land on that deck (TakeOffRWCategories / LandRWCategories, captured into dcsUnits.yaml).
  • add_sound(mission_path, sound_path, resource_name) — copies a .ogg/.wav into l10n/DEFAULT and declares it in mapResource as MCP_Sound_<name>; edit_route transmit_message plays it (a wrapped TransmitMessage, file = the key, loop, duration, subtitle written to the dictionary).
  • set_briefing_picture(mission_path, source_path, side, resource_name) — copies a .png/.jpg into l10n/DEFAULT, declares it in mapResource as MCP_Picture_<name> and appends the key to the side's pictureFileNameB / R / N, keeping the pictures it had (FIX-DEMO-MISSION-FINDINGS 07).
  • build_mission_briefing(mission_path, title, strategic_situation, context, mission_bullets, objectives, threats, air_opposition, air_defences, other, deployment, waypoint_notes, flights, code_words, recon_folder, diversions, kneeboard, pilots, loadouts, basemap, side) — an objective mission's VEAF briefing with no script (FEAT-MCP-MISSION-BRIEFING): docs/briefing.pptx, its PDF docs/briefing.pdf (drawn by fpdf2 with the bundled DejaVu font: the same pages and words, a layout of its own, no Office needed; pdf_pages in the result), docs/maps/ and the DCS briefing pictures, in the folder and the built .miz, first in pictureFileNameB / N under MCP_Briefing_* keys replaced on every call. The assistant passes the words; the rest is read from the built .miz (mission_briefing/), the injected presets (Radio[n].channelsNames) included for the frequency plan. basemap: topo takes OpenTopoMap tiles, with their credit and their own cache folder. FEAT-OBJECTIVE-BRIEFING-QUALITY: strategic_situation and deployment make a page each, left out when empty; a string is prose, its paragraphs separated by a blank line (mission_bullets as a string loses its dashes); flights names a dynamic-slot package's flights, listed under their base instead of the types it offers; waypoint_notes (by waypoint name) goes on the flight plan's step-by-step sheets. The overview is framed by MapCanvas(width=…, rotation=…): 2000 px at least, symbols in proportion, turned by best_rotation when the 2:1 frame gains 15 %, a north arrow, its key drawn inside; the zooms carry an inset (Inset). The targets and the steps are CardSections (mission_briefing/briefing_cards.py), drawn in the deck and the PDF; the deck and the PDF embed the maps as JPEG. FEAT-OBJECTIVE-MISSION-V2: a "combat area" map (render_overview_map(combat_area=True), docs/maps/combat-area.png, a DCS briefing picture too) follows the overview; the kneeboard maps are turned attack axis up (attack_axis_rotation, true bearing from the base to the objectives), a north arrow on each; code_words (word, meaning, who) makes a "Code words" page after the deployment, and a kneeboard page. FIX-BRIEFING-PARITY-MARTEAU: the overview frames the theatre (bases, tankers, AWACS, the QRA zones' centres, the lines the players' side drew on the map), the QRA zones dashed in orange, a SAM's reach outlined on a faint tint; an objective carries the name of the flight-plan point on it (objective_waypoint), the helicopters' route is green and dashed; the combat area adds the friendly ground units, the JTAC (a group named JTAC…), the routed groups' axes and each threat's range on its label (ground_picture). A target is a unit when its units carry names the author gave, otherwise its type or its VEAF spawn command's family, with its role (DCS attributes) and its bullseye; objectives[].flights gives an objective's "For:". With an operation in mission.yaml, its tasks are the main objectives, the others secondary, a chained zone in neither. With flights, the frequency plan and the kneeboard flight card follow the types the flights fly, one block per radio plan. The PDF is left-aligned; the ATO, the deployment (<when> — <who>: <what>) and the code words are tables. waypoint_notes takes {"plane": {…}, "helicopter": {…}}; kneeboard_replace removes the kneeboard pages that are not the tool's (folder → .veaf-backups/kneeboard-<date>/, and every .miz). FIX-BRIEFING-PARITY-MARTEAU-2: the overview draws and frames the departure bases (with dynamic slots, those of the flights, positions from airdrome-positions.yaml) and the enemy's airfields, the tankers' flight levels, and leaves out a drawn line that only retraces a route; each route leaves from its flights' base. The combat area names the objectives as the flight plan does and has no QRA arcs; a key taller than 40 % of the map takes two columns, in the corner the labels leave free, above the scale and the credit. A frequency block says which flights carry it on which radio. objectives[].status (main, secondary, prerequisite) wins over the operation. The kneeboard flight card is a table with the altitude. The ATO gives the tankers' level; support (callsign, role, frequency, details) adds a JTAC, the fat cow, a GCI. FIX-BRIEFING-MAGNETIC-HEADINGS: every heading and bullseye bearing the briefing prints is magnetic, °M, as the cockpits read them (the F-16C manual gives the bullseye "in degrees magnetic"; the F/A-18C shows magnetic unless TRUE is set on the HSI): the true bearing on the WGS84 ellipsoid less the declination of the World Magnetic Model at the mission's month and year, the model DCS uses (veaf_libs/magnetic.py). They used to be taken on the DCS grid, which is neither: 6.9 degrees off on the Golan. A carrier's course and a recon photo's heading are on the grid in the mission and the sidecar, as the Mission Editor and the DCS camera hold them: they are turned to magnetic where they stand, in the captions and on the photo's banner (whose rose points to true north, as the maps). The ranges are measured on the WGS84 ellipsoid, no longer on the grid, which overstated them by its scale (0.1 % on the Golan, 1 % at Tbilisi). Recon photos (mission_briefing/briefing_recon.py) — the interface the in-game lot fills: a folder (recon_folder, relative to the mission folder or absolute; docs/recon/ by default, ignored when absent) of pairs <stem>.jpg (or .jpeg) + <stem>.json, UTF-8:
  • build_mission_promotion(mission_path, title, event, tagline, pitch, program, practical, aircraft, closing, calendar_url, briefing_url, recon_folder, side) — a built mission's announcement (FEAT-OBJECTIVE-MISSION-V2): docs/poster.jpg (1920 × 1080), docs/calendar-banner.jpg (2400 × 432, the VEAF calendar's 5.55:1) and docs/discord-post.md, refused beyond Discord's 2,000 characters (mission_briefing/mission_promotion.py). Background: the first recon photo (by order) graded, or the map; the build_mission_briefing combat-area map and the photo pinned on it. The post's practical lines come from the built .miz (theatre, time of day, sky, dynamic-slot bases or flights). event (local date and hour) and the links are parameters, never invented: missing, warnings says so. The result gives the text back (discord_post, discord_chars).
key required meaning
zone yes the objective's combat zone, as objectives[].zone (case-insensitive)
title yes the caption, in the mission's language
heading no camera heading, degrees on the mission's grid as the DCS camera holds it (what veaf-tools dcs recon-shoot writes): the photo's axis; printed magnetic, °M
pitch no camera pitch, degrees, negative looking down
x, y no camera position, DCS map coordinates (x north, y east); printed as DMS
altitude_m no camera altitude, metres; printed in feet
order no sorting within the zone (then file name), default 0

After each objective's zoom, its photos two to a slide (deck and PDF) and two to a kneeboard page, captioned "title — heading 095°, pitch -12°, from , " with what the sidecar gives. The recon.json index veaf-tools dcs recon-render writes beside them is ignored. A photo with no sidecar, a sidecar with no photo, no zone or title, or a zone that is no objective: skipped, in warnings. Two photos per objective at most (one by default): the next ones in order (order, then the name) are skipped, in warnings — Marteau du Golan had six for one objective. The result lists the photos used in recon.

Syria Open Training (FIX-OPEN-TRAINING-SYRIA-FINDINGS)

  • Callsign from the name: a western flight named like its callsign (Texaco 2, Magic 1) gets that callsign (Texaco21) when the family fits the task and the flight is free; otherwise the former rule (first free family) and a warning saying why. An AI flight with a fighting task (Escort, CAP, CAS, SEAD…) and no pylons is warned about (ticket 01).
  • set_unit_properties: a callsign.name without digits is completed (Texaco → Texaco21); pylons takes {station: CLSID} as well as {station: {CLSID: …}} and refuses any other value, naming the station (ticket 02).
  • create_qra no longer writes simple_groups when groups_by_enemy_count is given: beside levels they never take off (ticket 03, measured in test_veafQraManager.lua).
  • Surface check: add_group, create_combat_zone, add_farp and set_group_properties' move warn about a vehicle, static or FARP at 0 m (in the sea) and a ship above 0 m (on land), where the theatre has an elevation grid; otherwise "surface not checked". 3 m is land: DCS never reads below 0 (ticket 04).
  • add_air_group(task="AFAC") — a laser drone like GermanyCW-v6's: first point with SetUnlimitedFuel then a Circle orbit at the group's altitude and speed. The lasing is CTLD's, from the modules.ASSETS entry (jtac, freq, mod); CTLD moves it to JTAC_droneAltitude, it designates vehicles only, within 10 km (ticket 06).
  • geocode: one request a second at most (Nominatim); a 429 waits what Retry-After asks (30 s at most) once, then the refusal comes back as found: false, said plainly. Five candidates asked for, a named place preferred to a road or a region; osm_class / osm_type in the answer, and a warning when it is a road or a region (« Al-Kiswah » → a street of Amman) (ticket 07).
  • list_unit_types gives threat_range_m / detection_range_m (metres, the datamine's ThreatRange / DetectionRange, the editor's circles) for every unit that has them; dcsUnits.yaml carries them, regenerated by veaf-build update-dcs-data --units (ticket 08).
  • DCS loadouts by name: add_air_group, create_qra (per group) and create_cap_mission take payload, the name of a loadout the editor offers (list_payloads), beside pylons and loadout_from — one of the three. Source: an install's MissionEditor/data/scripts/UnitPayloads (613 loadouts, 45 types), the datamine not carrying those files (ticket 09).
  • repair_static_shapes(target) — fills the shape_name of every static placed without one (before 6.26) from the units database, says what it filled and the statics whose type has no known shape; writes nothing when there is nothing to fill. validate's message names it (ticket 17).
  • The backups of a file under src/mission/l10n/DEFAULT/ go into .veaf-backups/ (they stayed beside it and went into the .miz), and a folder save rewrites mapResource (tickets 15, 16).

Marianas Open Training (FIX-OPEN-TRAINING-MARIANAS-FINDINGS)

  • create_qra: a group carries no task on its point, whatever its task (CAP or Intercept). With task: "CAP", the editor's automatic EngageTargets made the build fly the route as written: one point, no orbit, and the interceptor landed instead of defending its zone (ticket 01).
  • add_carrier_group: the recovery tanker takes the Shell family from flight 9 down (Shell91, then Shell81 for the next carrier), and tanker_callsign ("Arco 5") imposes one; the result says which in tanker_callsign. The tankers named Texaco 1, Arco 1 placed afterwards keep theirs (ticket 02).
  • add_air_group takes loadout_from, as the composites do: a group of the mission, then of the folder's catalogues, then of the shipped ones (a veafSpawn-* template). It is the only sourced loadout of a module aircraft with no DCS named payload (J-11A, F-16C); one of pylons, payload and loadout_from at most (ticket 03).
  • Shipped dynamic-slot catalogue: the J-11A templates carry the air-to-air loadout of the Marianas Open Training v5 (veafSpawn-J-11A - FOX3 - ECM ON - Radar ON - HARD X1), so loadout_from: "J-11A Template Red" arms a flight. The A-4E-C, OV-10A and T-45 templates, three mods, leave it, and the build warns when a template names a type the DCS unit database does not know (ticket 04).
  • describe_mission reads a mission folder; describe_units gives each group's group_id and each unit's unit_id (edit_route add_task attack_group takes the former); terrain_elevation takes mission_path and reads the theatre from it (theatre still works) (ticket 05).
  • geocode warns when it answers an island or an area for a place inside it: « Umatac, Guam » gave the centre of Guam without a word (ticket 05).
  • add_group and each group of create_combat_zone take roe (weapons_free, return_fire, weapons_hold) and disperse, written on the first point as the editor writes them (Option 0; Option 8 with no value for "does not disperse"). roe: "weapons_hold" and disperse: false make an inert target (ticket 06).
  • With no elevation grid or no clear-ground catalogue, the placement warning says what was not checked (the sea, the trees, the buildings) and how to check it: veaf-tools dcs clear-ground-check on the built mission. On MarianaIslands nothing is checked until the sweeps are done (known limitation marianas-placement-checks-nothing, ticket 07).

Mission settings (FIX-SCRATCH-MISSION-FINDINGS ticket 07)

What the editor sets outside any group, and GermanyCW-v6 had to patch through a Lua serializer. Each targets a mission folder (durable) or a .miz, backed up first.

  • set_mission_date(target, date?, start_time?) — mission.date (YYYY-MM-DD) and mission.start_time (HH:MM[:SS], the theatre's clock). The weather variants still override both.
  • set_bullseye(target, coalition, position) — mission.coalition.<side>.bullseye, the source of every flight plan's BULLSEYE waypoint.
  • set_route(mission_path, plan, points, coalition?, category?, aircraft_type?, country?) — a players' flight plan in the folder's src/waypoints.yaml: the points in order (x/y or lat/lon), each set on the ground from the theatre's grid, a repeated name refused, the plan replaced with its old points; the file's comments are kept. Returns each leg's heading (magnetic, at the mission's date) and distance (on the ellipsoid), and names a plan that would still win over it (WaypointsManager.get_flight_plan_for: the most specific, then the first declared).
  • set_briefing(target, sortie?, situation?, blue_task?, red_task?, neutrals_task?) — the briefing texts. Where the mission table holds a DictKey_… reference, the text goes into l10n/DEFAULT/dictionary behind it, so the reference stays valid; write_mission_folder now writes that dictionary back, only when it changes.
  • set_weather(target, metar?, temperature?, wind_speed?, wind_direction?, visibility?, cloud_type?, cloud_height?, precipitation?, fog_enabled?, clearsky?, reference_airfield?, wind_gust?, turbulence?, wind_veer?, wind_speed_2000?, wind_direction_2000?, wind_speed_8000?, wind_direction_8000?) — the base mission's weather, in the fields DCS reads (ticket 19: the blank mission has its clouds on the ground, Preset1 at 0 m). Same vocabulary and converter as versions[].weather, so a METAR works too; the variants still override it at build. wind_speed is the wind wanted 10 m above the reference airfield, not a DCS setting (FEAT-MISSION-WIND-CHECK): the answer gives reference_airfield (name, elevation, how it was chosen), simulated_wind (the wind DCS will simulate above it, by layer: surface, 1,000 and 3,000 ft above the ground, FL100, FL250) and warnings — sea level used for want of an airfield, a jet in the low layer, and with a helicopter slot, every place where the simulated wind exceeds 20 kt 0-600 m above the ground (the wind).

Password hashes (veafSecurity.password_L9[...] / password_MM[...]) — a multi-line case — are not covered yet: only the SecurityDisabled flag is.

VMCT actions on mission.yaml (wave 4)

The fourth family — the first genuinely VMCT one: edit the declarative source mission.yaml (what the build consumes to generate the .miz), rather than patching a built artifact. Shared brick: mission_tools.mission_yaml_editor (ruamel.yaml round-trip mode) which preserves comments, key order and formatting — essential for a heavily-commented source file edited by hand and kept in lockstep with the shipped default. Timestamped backup before every write.

describe_mission_config

Read-only. Lists the modules: block and, per module, its state: mandatory (bare key), scalar (boolean MODULE: true/false) or extended (nested config block such as COMBATZONE/CTLD). The VMCT counterpart of describe_mission.

{"mission_yaml_path": "path/to/mission.yaml"}

set_mission_module

Write. Enable/disable a module or set its extended config block, comments preserved. value is either a boolean (scalar form) or an object (extended block). The key is replaced if present, inserted otherwise. No deduplication.

{
  "mission_yaml_path": "path/to/mission.yaml",
  "module_id": "COMBATZONE",
  "value": {"enabled": true, "combat_zones": [{"type": "zone", "zone_name": "CZ-Alpha"}]}
}

Deliberately generic (toggle + mapping setter) — no per-module schema validator: the shape of the config block passed stays the caller's (LLM's) responsibility, like unit types for add_group.

Recipe / built parity (wave 7)

Every setting editable on the built veaf-config.lua (wave 3) gets its source mission.yaml counterpart, so both targets are reachable. Separate actions (consistent with set_mission_module), on the mission_yaml_editor brick:

Setting Recipe (mission.yaml) Built (veaf-config.lua)
Log level set_mission_log_level → global_log_level set_log_level
Security set_mission_security → security: block (+ password hashes) set_security_disabled
Arbitrary setting set_mission_setting → settings.<key> set_veaf_config → veaf.config.<key>
Module enable set_mission_module (wave 4) set_module_enabled
Options enforced on players set_forced_options → mission.forced_options — (the build writes them into the mission's forcedOptions)

Domain-knowledge oracle (wave 5)

The actions above are the LLM's hands (writes) and eyes (describe_*). Wave 5 gives it a brain: read-only actions exposing the DCS + VEAF knowledge needed to author correctly. All read from the canonical sources the build already uses, so they cannot drift:

  • generated DCS data (update-dcs-data → veaf_libs/data/dcsUnits.yaml, published on the VEAF GitHub);
  • VEAF aliases (veaf_libs/data/veaf-units.yaml);
  • vendored artifacts (vendored.yaml, check-vendored);
  • upstream datamining repos (provenance).

Implementation: veaf_mission_mcp/oracle.py. The "prose / how to reason" half is the authoring guide, veaf_mission_mcp/data/authoring-guide.md, served by describe_authoring_guide — the server = hands, the guide = brain.

list_unit_types

Read-only. DCS unit types from the generated database, filterable by category and/or name_contains, so the LLM can pick concrete types.

{"category": "Plane", "name_contains": "su-27"}

list_countries (FIX-OPENCODE-TEST-FINDINGS lot)

Read-only, no mission needed. The DCS countries as {id, name, short?}, in id order, from veaf_libs/data/dcs-countries.yaml: the country_id and country_name that add_group, add_air_group, add_farp and the composites take. name_contains matches the name, the Mission Editor's name and the short code. An assistant with no such action read the file out of the executable's unpacked folder.

{"name_contains": "turk"}

list_payloads

Read-only. The default loadouts the Mission Editor offers an AI aircraft type, by name (« R-40T2,R-334 » for a MiG-31), with their pylons — what payload takes in add_air_group, create_qra and create_cap_mission. Without unit_type, the types that have some. Read from veaf_libs/data/payloads.yaml, generated from a DCS install (veaf-build update-dcs-data --payloads --dcs-path <DCS>): the datamine does not carry these files. The generator reads the editor's own UnitPayloads folder and every module's (CoreMods/**/UnitPayloads): the module aircraft (P-51D, P-47D, Fw 190, F-16C, FA-18C…) are in. It lists the few files that are real Lua rather than data (a CLSID computed from a local) and leaves their types out — the Spitfire has no file at all in the install. The two AI bombers of a WW2 mission have no file either: their default loads (Ju-88A4 « SC5002,SC5010 », B-17G « M64*12 ») are written by hand in veaf_libs/data/payloads-ai-bombers.yaml, read from the install's db.Units.lua.

{"unit_type": "MiG-31"}

list_shortcuts

Read-only. The VEAF alias vocabulary (shilka, sa8…) — unit aliases (_spawn unit <alias>) and composite group aliases (_spawn group <alias>: SAM sites, convoys), plus the #command shortcuts (-samLR, -armor…) with randomParameters: the {min, max} range of each parameter they draw on every use — -samLR and -samSR run the same command and differ only by their defense range. Filterable by name_contains.

Every entry carries an era, read from what it spawns and never typed beside it (veaf_libs/shortcut_eras.py): follows_era (-aaa, -samSR, -samLR, -samVLR, -armor, -infantry, -transport, -combat, -convoy, -cas: the units come from tables keyed by mission.era), ww2 (-freya, -wurzburg, -flak88, -lefh, -pak40, -wespe), post_ww2 (fixed equipment of a later age: -ewr, -arty, -sa6…), era_free (spawns nothing) or unknown (-jtac, -afac, -cargo: not measured, so not claimed). era (WW2, COLD_WAR, MODERN) keeps what is worth offering in a mission of that era. validate warns about a #command or #veafInterpreter of a post_ww2 shortcut in a WW2 mission.

describe_known_limitations

Read-only. For the running veaf-tools version, the entries of veaf_libs/data/known-limitations.yaml: the tool limitations not yet fixed (kind: tool, dropped from the version in their fixed_in) and the DCS behaviours that raise no error (kind: dcs, always returned, with the date they were measured). Each entry: id, kind, area, title, symptom, workaround, optionally cost and theatres. The file is the one source: docs/agents/dcs-runtime-traps.md is generated from it.

The whole text is about 74 000 characters, more than a tool result carries. With no argument the action returns a short listing — id, kind, area and theatres when set, no title, about 7 000 characters — and the count of each area. area (spawning, air-defence, players, radio, ground-ai, scripting, mcp, pipeline, runtime), kind and theatre narrow that listing and keep it short; theatre drops the entries that concern other theatres only (an entry with no theatres applies everywhere). Read the entries you need in full with id or ids (a list); full: true returns everything selected. An area used to come back in full: three of them were 46 000 characters in one call, carried to the end of the session (FIX-OPENCODE-TEST-FINDINGS).

{"ids": ["ai-ground-start-needs-a-parking-capture"]}

describe_authoring_guide (FEAT-AI-ASSISTANT-ANY-CLIENT lot)

Read-only, no parameter. Returns {"guide": …}: the authoring guide, veaf_mission_mcp/data/authoring-guide.md, verbatim. The build bundles the whole veaf_mission_mcp/data/ folder into the exe; in development the action reads the package's file.

The server instructions (the MCP protocol's instructions, sent to the client on connection) ask it to call this action first, then describe_known_limitations. They are a few lines on purpose: a client puts them in every session's prompt, so they point at the guide rather than carry it. Not every client honours them; the install page therefore opens the conversation with the explicit request.

{}

read_mission_file / edit_mission_file (FEAT-AI-ZERO-SETUP lot)

A mission folder's YAML sources — mission.yaml and src/*.yaml, nothing else, no .., no absolute path — for an assistant with no direct file access. In Claude Desktop's chat mode, an assistant asked to rename a mission answered that no action could; mission.name, mission.era and the src/*.yaml files the guide's checklist asks it to adapt had no action at all. read_mission_file returns {file, first_line, last_line, total_lines, content}, 400 lines by default (start_line, line_count): the slot catalogue runs to 11 000. Its lines end in \n whatever the file uses. Without mission_path it reads the scaffold shipped with the tools — what scaffold_mission lays down: the dynamic-slot templates, the presets plan, mission.yaml with each module's commented example — and says shipped: true, so a plan can be checked before any folder exists. edit_mission_file replaces one exact passage (search / replace), refused when it occurs zero or several times or when the result no longer reads as YAML; backed up first. Line ends are compared normalised, and the file keeps its own: a passage copied back with \n from a CRLF file was "not found" (FIX-OPENCODE-TEST-FINDINGS). Implementation: veaf_mission_mcp/mission_files.py.

{"mission_path": "C:/VEAF/missions/convoi", "file": "mission.yaml", "search": "name: My-Mission", "replace": "name: helico_convoi_Montagnes"}

describe_mission_prompt (FEAT-AI-ZERO-SETUP lot)

Read-only. Returns {"kind", "lang", "prompt"}: the instructions that build one kind of mission end to end — the questions to ask, the design rules, the build order. kind is objective (a mission played once, in one session) or open-training (a VEAF training mission on a whole map); lang is en (default) or fr. The prompts live in veaf_mission_mcp/data/mission-prompts/, bundled with the guide; they used to sit in the repository's .prompts/, for the user to paste. The authoring guide sends a user who asks for a new mission here, so "create a mission" is enough. The objective prompt comes in two parts, cut at a <!-- part: build --> line: part: scenario (the default, about 13 000 characters, which ends by saying how to load the rest) and part: build, once the user has approved a scenario; part: all returns both. Sections 4 to 9, 21 000 of its 34 000 characters, serve only after approval, and were carried through every turn of the scenario discussion (FIX-OPENCODE-TEST-FINDINGS).

{"kind": "objective", "lang": "fr"}

offer_clear_ground_check (FEAT-CLEAR-GROUND-AT-AUTHORING lot)

Read-only, and it launches nothing. On a built .miz, returns what it takes to offer the user an in-game check: the veaf-tools dcs clear-ground-check command to run (it needs DCS), what it will do, the number of vehicles and markers, and the counting criterion. The check probes each vehicle's position on the empty survey mission: no vehicle exists there, so none is counted as blocked by its neighbours — the trap that made three published figures wrong. It then compares each answer with what the catalogue predicted.

{"miz_path": "path/to/mission.miz"}

offer_scenery_lookup (FEAT-OBJECTIVE-MISSION-PROMPT lot)

Read-only, and launches nothing. To use a map object (a bridge, a building that is part of the map) as a combat zone objective, you need its DCS id — the zone's scenery_targets takes nothing else, and those ids exist only inside DCS. The action returns what it takes to offer the user the veaf-tools dcs scenery-objects command (it needs DCS), which lists the objects around each point with their id, type and distance. Default radius: 150 m.

{"theatre": "Syria", "points": [{"x": -64230.0, "y": 352140.0, "radius": 100}]}

terrain_elevation (lot FEAT-TERRAIN-ELEVATION)

Read-only, no DCS: answers from the theatre's elevation grid, swept once by veaf-tools dcs terrain-sweep or downloaded from the published ones on first use (terrain data). Four questions, combinable in one call:

  • points — the ground height at each point, in metres and feet (a target's altitude);
  • route — the highest ground of each leg (the floor of a low-level route);
  • route + observers — how many metres of each leg each radar or SAM sees over the terrain, within its range, 4/3-Earth radar horizon. Every route point then needs its alt: a leg between two RADIO points (above the ground) follows the ground, any other is straight between the two altitudes;
  • area — the highest ground per 10 km MGRS square (the F10 grid) or 30′ quadrangle.

Terrain only — no buildings, pylons or trees — and interpolated between two samples: every answer carries the grid spacing and that caveat. A point off the grid is null, never 0. With no grid for the theatre, the answer is available: false with the command that sweeps one.

{"theatre": "Caucasus",
 "route": [{"x": -281000, "y": 647000, "alt": 60, "alt_type": "RADIO"},
           {"x": -262000, "y": 690000, "alt": 60, "alt_type": "RADIO"}],
 "observers": [{"name": "SA-6 Kutaisi", "x": -284500, "y": 683500, "range": 25000}]}

describe_naming_conventions

Read-only. The 8 reserved naming patterns (combat-zone membership, veafSpawn-/OnDemand- prefixes, #veafInterpreter[…]/#command= markers, QRA deploy entries, fixed CAS names…), each with its rule and the consuming module. Check a proposed name against these before add_group.

describe_module

Read-only. A locator (not a schema validator): confirms a VEAF module exists (via the canonical lua_module_scanner list), returns its doc page, and — when mission_yaml_path is given — its enabled state. Each module's config keys live in its doc page. It knows the community scripts the build ships too (CTLD, CSAR, AIEN, STTS, TUM, MIST): kind is veaf or community.

{"module_id": "QRA", "mission_yaml_path": "path/to/mission.yaml"}

Composites — one pass, both worlds (wave 8)

High-level actions that lay down a complete feature in one call, on a mission folder: they edit the durable source (the exploded src/mission/ — zones/groups — via mission_folder, and mission.yaml), without triggering a build (a later veaf-tools mission build produces the .miz). They orchestrate the wave-1..7 primitives (insert_trigger_zone, insert_group_into_content, the mission.yaml editor). Implementation: veaf_mission_mcp/composites.py.

create_combat_zone

Trigger zone + groups placed inside (names auto-prefixed with the zone → captured at runtime, coalition-agnostic) + an appended modules.COMBATZONE.combat_zones[] yaml block. Each group takes route and patrol, shaped like add_group's: a convoy moving through the zone is one of its groups, not a separate call. In category ship, ships are placed 600 m apart (a vehicle's 20 m made them collide as they spawned).

add_combat_operation

Writes mission.yaml only: a combat_zones[] entry of type operation — tasking_orders (each task, its dependencies), friendly_name, briefing, active_at_start. An operation's name is a label, not a trigger zone: nothing is written to src/mission/. Refuses a task or a dependency that names no declared combat zone (or names another operation): the generated GetZone() would be nil at runtime (FIX-DEMO-MISSION-FINDINGS 07). Activating the operation spawns all its zones at once; dependencies only decide when a task becomes the current objective.

create_qra

Trigger zone + Late-Activation interceptors (coalition-significant) + a modules.QRA.definitions[] entry referencing the groups by exact name (simple_groups). Coalition is lower-cased for placement, upper-cased in the YAML definition. Each interceptor has a single waypoint and no task, on purpose: when it scrambles, the QRA module gives a CAP/Intercept group whose route engages no aircraft a patrol across the zone (what a scrambled group does). Its description asks for tiers by intruder count up to the squadron's expected size — never a single fixed pair against five players or more — and names scale_with_opposition and rearm_while_occupied (opposition level). scale_with_opposition: true is written by default (the caller's qra may set it false), and the root opposition: {level: 0} block is added when the mission has none, so the Opposition menu exists and the mission starts unchanged. An interceptor takes off from the runway by default: airfield's, else its coalition's airfield nearest the zone; start: air puts it in the air at position, only when asked. With no airfield of its coalition, or no parking data for the theatre, the default falls back to the air with a warning; a start or an airfield asked for and impossible is refused.

create_cap_mission

A Late-Activation template group named OnDemand-<name> + a cap_missions[] entry (group_name: <name>, un-prefixed — the build resolves it to the OnDemand- group). The first point carries the EngageTargets task (Air targets) the editor adds on its own to a CAP task, numbered before the orbit: without it the flight patrols and never engages.

menu_name is the name the F10 MISSIONS menu shows (the mission name when omitted: an empty one made a blank entry whose "Infos" failed), briefing what its Infos say. A cap key the build does not read (friendly_name, the combat zones' word) comes back in warnings, and validate_mission warns about it too.

Scaffolding a mission folder (wave 9)

Every action above assumes a mission folder already exists. Wave 9 provides the upstream piece: create that folder from an empty one, driving the real VEAF binaries the way a Mission Maker would on first install.

scaffold_mission

Write. On an empty target folder:

  1. Resolve the current OS's updater asset (veaf-tools-updater.exe on Windows, veaf-tools-updater-<os>-<arch> on Unix) and download it from the stable release-download URL (…/releases/download/<tag>/<asset> — no GitHub API, no rate limit).
  2. Run the updater in the folder (it fetches and installs the VEAF tools + published/).
  3. Run veaf-tools mission prepare --template <tier> --force in the folder.
{
  "target_folder": "path/to/empty-folder",
  "template": "standard",
  "github_token": "…",
  "tag": "published-latest"
}
  • Refuses a non-empty folder — scaffolding only initializes an empty one.
  • template — minimal / standard / full. The interactive custom tier is not supported here (its TUI picker has no TTY under a subprocess); the calling LLM must ask the Mission Maker which template and pass it as a parameter.
  • theatre — optional; relayed to prepare --theatre to lay down a synthetic blank mission for that DCS map into src/mission/ (no DCS round-trip). Omitted → src/mission/ stays empty (the maker supplies their own .miz).
  • github_token — optional, relayed to the updater (--token) to bypass the API rate limit.
  • A non-zero exit from the updater or prepare, or a missing veaf-tools/published/ after the updater, surfaces as an explicit error.

This is step 0 of a from-scratch mission, before the wave-8 composites.

Map & coordinates (wave 10)

Placement actions take DCS local coordinates (x/y, metres in the theatre's own projection); a Mission Maker thinks in lat/long off a map. Wave 10 gives the LLM map awareness and conversion, design-time (no running DCS).

Foundation: veaf_libs.coordinates — pure-Python Transverse Mercator WGS84 (no pyproj), whose per-theatre constants come from the vendored data/dcs-maps.yaml (MIT export of VEAF/dcs-maps, see ADR 0015) — all DCS theatres (Caucasus, Syria, PersianGulf, Marianas, Normandy, Nevada, SinaiMap, GermanyCW, Kola, TheChannel, Falklands, Afghanistan, Iraq). Since DCS theatres are the real world projected, this bridges DCS x/y ↔ real-world lat/lon.

describe_map

Read-only. From a .miz or a mission folder: returns the theatre, per-coalition bullseyes, and existing zones/groups as reference points — so the LLM can orient without DCS. Each zone carries its x/y/radius, each group its x/y and its number of units.

{"mission_path": "path/to/mission.miz-or-folder"}

list_airfields

Read-only. Lists a theatre's airbases — name, DCS airdrome id, and DCS x/y in whole metres when the theatre's projection is known (lat/lon otherwise), plus each base's coalition with a mission — from the data shipped with the tools (veaf_libs/data/airdrome-positions.yaml, generated with airdromes.yaml from the dcs-world-schema reference database by veaf-build update-dcs-data --airdromes). The position is the airbase's reference point, the runways' centre. With mission_path, the mission's theatre; without a mission, theatre. name_contains keeps the bases whose name holds a text; details: true adds lat/lon, parking (the stands usable by planes and by helicopters) and atc (uhf, vhf, fm MHz and tacan, as DCS gives them). Short by default: every field for Syria was 37 000 characters, 21 000 now (FIX-OPENCODE-TEST-FINDINGS).

{"theatre": "Syria", "name_contains": "incirlik", "details": true}

describe_airfield_channels

Read-only. Lists, for a mission folder, the airfields it uses — side (from warehouses), dynamic slots once src/warehouses.yaml is applied, player slots parked there — with the ATC frequencies and TACAN DCS itself gives them (veaf_libs/data/airfield-frequencies.yaml), and the alias each already has in the bases collection. Ranked: held with slots, held without, then (include_neutral) neutral. It is the proposal to put to the mission author before set_airfield_channels: a DCS radio holds about twenty channels. Same code as veaf-tools content airfield-channels.

{"folder_path": "…", "include_neutral": false}

set_airfield_channels

Writes the chosen airfields into the bases channel collection of the folder's src/presets.yaml, with DCS's frequencies; refuses an airfield DCS does not declare. An airfield already in bases keeps its alias and the spelling of its title (Büchel for DCS's Buchel), a new one is aliased Base-<DCS name>, and an entry matching no chosen airfield (a FARP, a ship) is left as it is and reported in untouched. Only bases changes; not_on_a_radio lists the written channels no channel_lists entry uses yet. Idempotent.

{"folder_path": "…", "airfields": ["Batumi", "Kutaisi", 31]}

resolve_coordinates

Utility. Converts a position between {x, y} (DCS local) and {lat, lon} (decimal degrees) for the mission's theatre (read from the mission — the caller never supplies projection parameters), or for a theatre named before there is a mission.

{"mission_path": "…", "position": {"lat": 42.18, "lon": 41.68}}

To convert several points in one call, pass positions (a list) instead of position: the answer is {theatre, points}, in the order given, and an incomplete position is named by its index.

geocode

Read-only (FEAT-GEO-PLACEMENT lot). Resolves a real place name to DCS coordinates for the mission's theatre — DCS theatres being the real world projected. Pluggable geocoder: OpenStreetMap Nominatim by default (free, no key; © OpenStreetMap attribution required), Google Maps when GOOGLE_MAPS_API_KEY is set. Optional bearing+distance_km ("10 km north of X"). Returns {found, display_name, latlon, xy, in_theatre_bounds, warnings} — approximate, confirm visually; named places work, vague terrain does not. Without a mission, theatre names the map.

{"mission_path": "…", "query": "Kobuleti", "bearing": 0, "distance_km": 10}

Build & validate (wave 11)

The earlier actions create, orient and edit a mission folder, but nothing produced the playable .miz — the maker still ran veaf-tools mission build by hand. Wave 11 makes the server self-sufficient end-to-end: empty folder → scaffold → theatre blank → composites/placement → validate → build → playable .miz, without leaving the assistant.

validate_mission

Read-only. Lints a folder before build: reuses veaf_libs.mission_validator in-process. Returns {ok, errors[], warnings[]} (ok = false on any error). Run it before build_mission.

{"folder_path": "path/to/mission-folder"}

inspect_built_mission (FIX-OPENCODE-TEST-FINDINGS lot)

Read-only. What a built mission holds, for the post-build check, compactly — one line per item, never a raw table: dynamic_slot_templates (the template types of each side, once), airbases and farps_and_ships (side, dynamic slots, hot start, the templates linked — "every blue template linked (61)" rather than the list again — and how many types are stocked with no template), presets (per side and radio layout, the types that carry it, or no presets), bullseye, weather (time, sky, ground wind and visibility of the mission and of each variant in missions/), support (tankers and AWACS, frequency, TACAN, late activation) and counters (presets_injected, routes_on_player_groups, warehouse_links, dead_links, variants), so a zero shows. A section the mission lacks reads absent. mission_path is the folder (its latest build) or a .miz. The guide used to ask to "open the built .miz and check": with only the actions, an assistant unzipped it and parsed its Lua tables in PowerShell. The shipped default mission, built, reads about 5 000 characters. Implementation: veaf_mission_mcp/inspect_built.py.

{"mission_path": "path/to/mission-folder"}

build_mission

Write. Builds the folder into a playable .miz by driving veaf-tools mission build in the folder (the binary scaffold_mission installed, or veaf-tools on PATH). The build pipeline lives in the CLI command and is re-run as-is. A build failure is surfaced (RuntimeError). profile (optional) is passed as --profile — LOCAL_TEST for a local test build.

{"folder_path": "path/to/mission-folder", "profile": "LOCAL_TEST"}

The result lists in missions the .miz the build wrote: the night's, and its training versions when mission.yaml holds a training: block.

propose_training_plan

Read. Drafts the training: block of a mission folder (training versions, one per role): enemy AI flights tasked for air combat → enemy_cap, cap_missions → enemy_cap, the enemy side's QRA definitions → qra, groups holding a type DCS rates LR SAM → sam_lr, combat zones → a2g_target, other air defences inside a combat zone or within 15 km of one → sam_threat. Everything is proposed as placed, each element with its reason: sorting what is stated from what is vague is done by reading the briefing, not in the action. The enemy side is the one opposite the side holding the most player slots (blue players by default).

{"mission_path": "path/to/mission-folder"}

set_training_plan

Write. Writes the training: block of mission.yaml (comments preserved, backed up first), after the build's own checks (mission_builder.training_variants.check_training): refused with every problem listed, otherwise the next build also produces _TRAINING_A2G, _CAP, _SEAD and _ALL.

{"mission_path": "path/to/mission-folder", "training": {"elements": [{"groups": ["SA-10 *"], "kind": "sam_lr"}], "randomize": []}}

Next waves (out of scope)

  • A generic IF/THEN trigger editor (arbitrary DCS conditions/actions) — wave 2 is limited to startup script-loading / Lua-execution triggers.
  • A per-module schema validator for set_mission_module (wave 4 stays generic).
  • CAS composites (pure runtime, no authoring) and end-to-end generation from a prompt (the NL-MISSION-GEN goal beyond this lot).

See .backlog/archive/FEAT-MCP-MISSION-EDITOR.md for details.