forked from 1b-agent/fieldcraft-upstream
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
197 lines
15 KiB
Markdown
197 lines
15 KiB
Markdown
# FieldCraft and AI agents (MCP)
|
||
|
||
FieldCraft speaks the [Model Context Protocol](https://modelcontextprotocol.io) 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
|
||
|
||
```sh
|
||
# 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
|
||
|
||
```sh
|
||
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`):
|
||
|
||
```json
|
||
{
|
||
"mcpServers": {
|
||
"fieldcraft": { "command": "/path/to/fieldcraft-cli", "args": ["mcp"] }
|
||
}
|
||
}
|
||
```
|
||
|
||
## Confining file access
|
||
|
||
```sh
|
||
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 `.fieldcraft` file opens
|
||
again, `file.recovery.list` and `file.recover` with a `dir`.
|
||
- Every file a command writes must lie inside the **write root**: `save`, `file.save` and
|
||
`file.save_as` (also a save to the model's own file), `write_deck` and `deck.write`,
|
||
`screenshot {path}`, `file.recovery.discard` with a `dir`.
|
||
- 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/x` is 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.inp` or
|
||
`automation 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 in `warnings`.
|
||
- The app's own recovery folder (autosaves, `file.recovery.*` without `dir`) 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 `<face>|<face>` with lengths, which sets name as `{body, edge}`). |
|
||
| `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`):
|
||
|
||
1. `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.axis` makes axes.
|
||
2. `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 are
|
||
`partition:<name>:a` and `partition:<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_model` lists the cells.
|
||
3. `mesh` with method `hex`, `hex_sweep`, `hex_mapped` or `tet` on one cell (`{"body":
|
||
"Part/b"}`), or method `cells` for 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).
|
||
4. `mesh` with method `mirror` (`{"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.
|
||
5. 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.
|
||
|
||
1. `inspect_model` lists each body's edges by persistent name (`<face>|<face>`, `~k` when several
|
||
separate the same faces) with their lengths; the names follow the faces through edits and
|
||
partitions.
|
||
2. `execute` `mesh.seed.edge {edges: [{body, edge}], count | size, bias?: {ratio, toward:
|
||
start|end|both}, constraint?: exact|min|free}`. `exact` keeps the count, `min` keeps at least
|
||
it, `free` is a goal. The reply gives the count each edge gets.
|
||
3. `mesh.seed.list` lists the seeds and the edges they reach (`stale` when 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
|
||
|
||
```text
|
||
→ {"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.
|