Seed design edges by their persistent names with a count or a size, a bias and a constraint; one undo step each, saved with the model. Seeds on edges a reopened design lacks are reported as stale. Part of #230. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
15 KiB
FieldCraft and AI agents (MCP)
FieldCraft speaks the Model Context Protocol over stdio
(JSON-RPC 2.0, one message per line; protocol revisions 2025-06-18, 2025-03-26 and 2024-11-05).
An agent builds and solves models the way a person does: every tool runs engine commands, the
same ones the UI, scripts, the command line and the control channel run, and never opens a
dialog or a file picker. The server is crates/mcp (fieldcraft-mcp); fieldcraft-cli mcp
runs it.
Start it
# Headless (the default): an in-process session, no window; screenshots are CPU renders.
fieldcraft-cli mcp
fieldcraft-cli mcp --headless
# Bridged to the running app, so you can watch the agent work:
fieldcraft --control 7070 &
fieldcraft-cli mcp --connect 127.0.0.1:7070
With --connect, an app that cannot be reached when the server starts gives a headless session
instead (a note goes to stderr). stdout carries only protocol messages; errors go to stderr.
Bridged screenshots are written to a temporary file by the app and read back by the server, so
both must run on the same computer.
Claude Code
claude mcp add fieldcraft -- /path/to/fieldcraft-cli mcp
# bridged to a running app:
claude mcp add fieldcraft -- /path/to/fieldcraft-cli mcp --connect 127.0.0.1:7070
# kept to one project folder:
claude mcp add fieldcraft -- /path/to/fieldcraft-cli mcp --automation-read-root /work/bracket --automation-write-root /work/bracket/out
Build the binary first (cargo build --release -p fieldcraft-cli; it is
target/release/fieldcraft-cli). The same setup as a project .mcp.json, or in another client's
configuration (Claude Desktop's claude_desktop_config.json):
{
"mcpServers": {
"fieldcraft": { "command": "/path/to/fieldcraft-cli", "args": ["mcp"] }
}
}
Confining file access
fieldcraft-cli mcp --automation-read-root /work/bracket --automation-write-root /work/bracket/out
The two flags keep the agent to the folders you give it:
- Every file a command reads must lie inside the read root:
open,file.open,geometry.open,geometry.import.mesh, the design and meshes a.fieldcraftfile opens again,file.recovery.listandfile.recoverwith adir. - Every file a command writes must lie inside the write root:
save,file.saveandfile.save_as(also a save to the model's own file),write_deckanddeck.write,screenshot {path},file.recovery.discardwith adir. - Read and write access are separate. Give the same folder twice for both; a root left out
grants none of its access (with only
--automation-read-root, nothing can be written). Without either flag nothing is confined. - A path is made absolute against the server's working folder, then its links are followed: a
file (or folder) link that leads out of a root is outside it. For a file that does not exist
yet, its nearest existing folder's links are followed and the rest must be plain names, so
..below a folder that does not exist is refused, as is a link that leads nowhere (writing through it would create its target). The result is compared with the root by whole folder names:/work2/xis not inside/work. On Windows device names (CON,NUL,COM1…) are refused. - A refused path is an ordinary tool error the agent can read and correct, for example
automation path rejected: outside the write root /work/bracket/out: /etc/x.inporautomation filesystem access is not granted: write authority is absent: /work/bracket/out/x.inp. A design or mesh a model refers to outside the read root is not opened; the model opens without it and says so inwarnings. - The app's own recovery folder (autosaves,
file.recovery.*withoutdir) is not confined: no agent names a path there. - The roots must be existing folders; a missing one stops the server with an error. Each flag
is given once (
--flag <dir>or--flag=<dir>).
The flags confine the headless server and imply --headless; --connect with them is an error,
since the server would otherwise drive an app that is not confined.
A path is checked, then opened: another program that swaps a link in between can still win that race, so the roots keep an agent to the files it was given rather than guarding against other software on the computer.
Tools
| Tool | Arguments | What it does |
|---|---|---|
list_commands |
filter? |
Every command with its label, parameter documentation and schema (its parameters as JSON Schema: types, units as x-dimension, defaults, required fields, one-of variants) and requires (what the session must hold first: design, geometry, mesh, material, step, result, …); filter matches id and label. |
execute |
command, params? |
Run any command (list_commands). A failure leaves the model unchanged and names what to fix; a command whose requires are not met fails before doing anything, naming each missing piece and the command that provides it. |
batch |
commands: [{command, params}] |
Run steps in order, stop at the first error; the reply has every result and the failing step (failed.index, failed.command, failed.error). Steps before it stay applied (undo takes them back). |
inspect_model |
— | model.info (counts, units, definitions), model.activity (the step table) and geometry.bodies (bodies with measures, persistent face names, which sets name as {body, face}, and persistent edge names ` |
check_model |
— | model.check: errors and warnings, each naming its item. |
open |
path, model? |
By extension: .fieldcraft (file.open), .solidcraft (geometry.open), .3mf (geometry.import.mesh). For a design, model is keep (default: the mesh and definitions stay, as a reopen after an edit wants) or new (a new model with the design); the reply's related is false when the design has none of the bodies the model refers to, and stale lists the mesh bodies and sets that no longer resolve. |
save |
path? |
file.save: to path or the model's own file. |
write_deck |
format (abaqus, nastran, lsdyna), path, steps? |
deck.write. |
mesh |
method, params? |
Runs the mesher (tet: mesh.tet.generate; hex, hex_sweep, hex_mapped, hex_blocks: the structured hex meshers; cells: mesh.cells, a partitioned part cell by cell; mirror: mesh.mirror, a meshed half reflected across a plane; block: mesh.block; planar: mesh.planar) and replies with its result, model.info and mesh.quality. |
solve |
analysis (static, frequency, explicit, fluid; heat as it lands), step?, options? |
solve.<analysis> with the step and the solver's caps and settings. |
show_results |
step?, field?, bands?, deformed?, scale?, mode?, frame?, time? |
The results.* commands its arguments ask for (deformed: false draws the undeformed mesh; scale is a factor, 0 for the undeformed shape so a sequence from 0 swings the deformation out, or "auto"); the reply has the contour range (smallest and largest value and their nodes: field: "displacement" gives the largest displacement), modes or frames. |
probe |
step?, nodes?, elements?, points?, forces?, mode?, frame? |
solve.probe: stored results at nodes, elements (explicit) or points (fluid). |
reactions |
step?, sets?, about? |
results.reactions: reaction force and moment per node set and in total. |
screenshot |
view?, camera?, source? (window, model), width?, height?, path? |
A PNG image content block and what it shows. Headless: a CPU render framing the model, the geometry, the analysis setup's symbols (boundary conditions, loads, reference points, couplings; sampled_symbols lists definitions drawn with a sample) and the results display (deformed shape, contour bands). Bridged: the whole app window drawn offscreen (ui.capture, the default) or a model render with the app's camera (ui.render). view turns the camera to a standard view first; camera {yaw, pitch, distance?} to any orientation, in degrees (yaw 0 looks along +Y, −45 is the iso view's; pitch is the elevation, clamped to ±90) and model units (ui.camera; headless, the picture is framed unless distance is given). Give one of the two; the orientation is kept for the next picture, so a sequence of camera angles orbits smoothly. |
select |
filter?, box_mode?, mode?, clear?, geometry?, nodes?, elements?, faces?, at?, box? |
Bridged only: changes the app's viewport selection (ui.select; control-protocol.md, Selection) and returns it. Headless it is an error: selection belongs to the window. |
selection |
— | Bridged only: the viewport selection (ui.selection), with params, the selection as a set's members for model.set.create. |
undo, redo |
— | edit.undo, edit.redo. |
Resources: fieldcraft://model (model.info) and fieldcraft://commands (the command catalog).
Errors: an unknown tool, or arguments that do not match the tool's schema (a missing or unknown
argument, a wrong type, a value outside an enum), are JSON-RPC -32602 errors; an engine failure
is a tool result with isError: true and the engine's message.
Partition, mesh half, mirror
The commands that cut a part into cells and build its mesh from a half, all through execute
and mesh (each one's parameters: list_commands):
geometry.datum {name, by}: a datum plane — a principal plane moved along its normal ({"type": "offset", "plane": "zx", "offset": 0}), three points, or a point and a normal.geometry.datum.axismakes axes.geometry.partition {name, datum, bodies?}: cuts the open design's bodies by the plane into cells<body>/a(negative side) and<body>/b. The two faces of the cut arepartition:<name>:aandpartition:<name>:b; every other face keeps the name of the face it is a piece of. The partition is part of the model: saving and opening it, or opening the design again after an edit, cuts the design again.inspect_modellists the cells.meshwith methodhex,hex_sweep,hex_mappedorteton one cell ({"body": "Part/b"}), or methodcellsfor all cells with a method each ({"methods": {"Part/a": "tet"}, "default": "auto"}): cells whose faces match on a partition are merged, others are tied (the reply lists each interface).meshwith methodmirror({"plane": "<datum>", "bodies": ["Part/b"]}): the meshed half reflected across the plane, right-handed, joined on it, its elements and faces tagged as the other half's, so sets by face name ({body, face}on either half) reach both.- Or keep the half:
model.boundary_condition.symmetry {name, datum}holds it on the plane (zero normal displacement), with half the load.
file.sample {"name": "crank-mirror"} opens the crank arm built this way (docs/examples.md).
Edge seeds
Seed an edge when its element count matters more than the global size: through a thin section's thickness (at least 2–3 elements for bending), around a fillet or hole where the stress peaks (a count or a small size, biased toward the stress raiser), or to give the shared edges of partition cells matching counts.
inspect_modellists each body's edges by persistent name (<face>|<face>,~kwhen several separate the same faces) with their lengths; the names follow the faces through edits and partitions.executemesh.seed.edge {edges: [{body, edge}], count | size, bias?: {ratio, toward: start|end|both}, constraint?: exact|min|free}.exactkeeps the count,minkeeps at least it,freeis a goal. The reply gives the count each edge gets.mesh.seed.listlists the seeds and the edges they reach (stalewhen the design has no such edge);mesh.seed.clear {edges?}removes them. Seeds are saved with the model.
Limits
The server treats its input as hostile. A message line may be at most 16 MiB (the control
channel's limit); a longer one is answered with request too large and skipped, and the server
keeps serving. A JSON-RPC batch holds at most 100 messages, a batch call at most 10 000
commands; JSON nested deeper than 128 levels is a parse error. Picture sides are clamped to
16–4096 pixels. Every command keeps its own caps (element counts, solver degrees of freedom and
factor size, iterations), and an engine failure or panic leaves the model as it was.
A typical session
→ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"me","version":"1"}}}
→ {"jsonrpc":"2.0","method":"notifications/initialized"}
→ {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"open","arguments":{"path":"/work/steps.solidcraft"}}}
→ {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"inspect_model","arguments":{}}}
← … "bodies": [{"name": "Steps", "faces": [{"name": "F1:-x"}, {"name": "F1:+x"}, …]}] …
→ {"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"mesh","arguments":{"method":"tet","params":{"body":"Steps","tolerance":0.5}}}}
→ {"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"batch","arguments":{"commands":[
{"command":"model.units","params":{"system":"mm-t-s-N-MPa"}},
{"command":"model.set.create","params":{"name":"all","geometry":[{"body":"Steps"}]}},
{"command":"model.set.create","params":{"name":"root","geometry":[{"body":"Steps","face":"F1:-x"}]}},
{"command":"model.set.create","params":{"name":"tip","geometry":[{"body":"Steps","face":"F1:+x"}]}},
{"command":"model.material.create","params":{"name":"Steel","elastic":{"type":"isotropic","young":210000,"poisson":0.3},"density":7.85e-9}},
{"command":"model.section.create","params":{"name":"solid","set":"all","material":"Steel","kind":{"type":"solid"}}},
{"command":"model.boundary_condition.create","params":{"name":"clamp","type":"fixed","target":{"set":"root"}}},
{"command":"model.load.create","params":{"name":"push","type":"force","target":{"set":"tip"},"vector":[0,0,-1000]}},
{"command":"model.step.create","params":{"name":"Load","procedure":{"type":"static"},"boundary_conditions":["clamp"],"loads":["push"]}}]}}}
→ {"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"solve","arguments":{"analysis":"static","step":"Load"}}}
→ {"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"show_results","arguments":{"step":"Load","field":"displacement"}}}
← … "contour": {"field": "displacement", "range": {"max": {"node": 25, "value": 0.00485}, …}} …
→ {"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"screenshot","arguments":{"view":"iso"}}}
← {"jsonrpc":"2.0","id":8,"result":{"content":[{"type":"image","mimeType":"image/png","data":"iVBORw0…"},{"type":"text","text":"{\"source\":\"model\",…}"}]}}
crates/mcp/tests/workflow.rs runs this session on a part of the meshing corpus
(tests/corpus/stepped-block) and checks the results only through MCP; crates/mcp/src/tests.rs
sends malformed messages, huge parameters, paths outside the roots and unknown tools.