Skip to main content
Glama
ansys-agent-bridge

ansys-agent-bridge

by 1622352030
README.md
# ansys-agent-bridge

An MCP server that drives **Ansys SpaceClaim** headlessly, plus a **DSH plugin
bundle** that installs it into a DeepSeek Harness profile in one command.

The point of this project is not to wrap every API. It is to report what
SpaceClaim actually did, in a place where the honest answer matters: two
operations on SpaceClaim 2024 R2 **return success and change nothing**, and an
agent that believes the return value will confidently describe geometry that
was never modified. This server measures the geometry before and after, and
raises `no_geometry_change` instead of reporting a success it cannot see.

## What it does

Nineteen MCP tools:

| Tool | Read-only | What it does |
|---|---|---|
| `ansys_bridge_doctor` | yes | Reports detected Ansys releases, `SpaceClaim.exe`, Fluent root, Python, and package versions. Imports nothing heavyweight. |
| `scdm_session_start` | no | Starts a SpaceClaim modeler (auto-detects the release; measured **14–31 s**). `hidden=false` by default, so you see the window. |
| `scdm_attach` | no | Connects to a SpaceClaim the **user** started (ApiServer add-in + fixed `API_PORT`). Consumes no licence, and cannot close their window. |
| `scdm_session_status` | yes | Whether a session is live, its mode, and whether a design is visible to it. |
| `scdm_session_close` | no | Releases the session and its licence. Launch sessions only; refused when attached. |
| `scdm_detach` | no | Drops the gRPC channel to a user-owned SpaceClaim. Nothing else. |
| `scdm_open_file` | no | Opens `.scdoc`/`.scdocx`/`.dsco`/`.pmdb` and reports bodies, named selections, face counts, volumes. |
| `scdm_list_bodies` | yes | Body names, face counts, volumes for the open design. |
| `scdm_collisions` | yes | Pairwise collision state (`TOUCH`/`NONE`/…), all pairs or a chosen list. |
| `scdm_min_distance` | yes | Minimum distance between two bodies in metres — the continuous counterpart to collisions. |
| `scdm_insert_file` | no | Merge another CAD file into the open design (`open_file` opens a document; this adds to one). |
| `scdm_transform` | no | `rotate` / `scale` / `mirror`, verified by a before/after spatial fingerprint. |
| `scdm_inspect_geometry` | yes | The eight official geometry checks: duplicate faces, short edges, small or missing faces, split and stitch faces, extra and inexact edges. Attributes short edges to tessellation chords rather than reporting a bare count. |
| `scdm_create_pipe` | no | **Creates** a hollow pipe or solid rod from a ring section and an extrude. No boolean. Verifies the annulus area and the extruded volume against the analytic values. |
| `scdm_extrude` | no | **Creates** geometry by extruding faces of a body already in the design (`Add`/`ForceAdd`/`ForceIndependent`). |
| `scdm_boolean` | no | `unite`/`subtract`/`intersect`, guarded by a before/after geometry check. |
| `scdm_share_topology` | no | Share topology, guarded by the same check. |
| `scdm_run_script` | no | Runs an IronPython script: `script_path=<file>` or `script=<source text>` (written to a temp file). |
| `scdm_export` | no | Exports `.scdocx`, STEP, IGES, Parasolid text/binary, `.pmdb` — with a file name **you** control (`output_dir` + `file_stem`). |

### Creating geometry

Before `scdm_create_pipe` existed, **no tool in this server could create geometry**:
every one either read the model or operated on bodies that already existed, so
"model from scratch" meant hand-writing IronPython. The route the creation tools use
was measured, and it deliberately contains **no boolean**:

```python
# a ring section built from two semicircles per circle, then extruded
Body.CreatePlanarBody(plane, profile, False, False, removeInnerRegions=True)   -> section
ExtrudeFaces.Execute(Selection.Create(section.Faces[0]), dir, MM(L), opts, None) -> tube
```

Why not subtract a rod from a rod: on this kernel `Body.subtract` returns normally,
deletes the tool body and leaves the target untouched. And why **read the result
back**: `removeInnerRegions=False` does not fail — it drops the inner loop silently
and hands back a plain outer disc (`1963.495 mm²` where the ring is `706.858 mm²`),
so an unguarded tool builds a solid rod and reports a tube. `scdm_create_pipe`
checks the section area and the volume against the analytic values and refuses
anything else.

Measured live, over MCP: a 50/40/200 mm pipe came back with 4 faces and
`141367.72 mm³` against an analytic `141371.67 mm³`; a 20/10/30 mm pipe with
`7068.394 mm³` against `7068.583 mm³`; an extrusion of a `235.6194 mm²` annulus by
5 mm added `1178.081 mm³` against an analytic `1178.097 mm³`.

### The two silent failures, measured

On a stator + 27 windings + pipe + inlet assembly (31 bodies, 846 faces):

**`subtract` fails, and the failure is easy to miss.** Target stator: 382 faces
and volume `0.003314505208 m³` before; **382 faces and the same volume after**.
The only thing that moved is the body count, 31 → 30, because `keep_other=False`
deleted the tool.

That matters for how you guard it. The obvious check — "did anything change?" —
answers *yes* here, so a guard built on it certifies the no-op. This server
therefore judges a boolean by **its target**: if the named target keeps the same
face count and volume, the call failed, whatever the body count says. The same
symptom appears through raw IronPython `Shape.Subtract`, so it is a
kernel/geometry problem with that model, not an API-path problem.

**`share_topology` returns `True` and does nothing.** Stator stays at 382 faces,
winding 1 at 16, bodies at 31.

**`unite` does work.** Stator faces `382 → 390`, volume
`0.003314505208 → 0.003382973513` (delta `6.84683e-5`, exactly winding 1's own
volume), total faces `846 → 838`, bodies `31 → 30`. Both number sets match the
raw IronPython result.

Full measured record, including the raw-IronPython traps (`Body[](n)` is a
parse-time error that kills a script silently; `Document.Load` breaks every
later `SaveAs`), is in [`spaceclaim/skills/ansys-spaceclaim/SKILL.md`](spaceclaim/skills/ansys-spaceclaim/SKILL.md).

### One trap before you build the CAD: Chinese names do not reach Fluent

Measured when a SpaceClaim STEP was imported into Fluent Meshing: the import printed
`Warning: ?????????` and **all three named collections were dropped**, leaving the
mesh with no usable named selections. The geometry arrived; the names did not.

So name layers and named selections in **ASCII** — `inlet`, `outlet`, `wall`,
`fluid` — if the model is going to Fluent. Named selections are what every
downstream boundary-condition assignment depends on, and they are cheap to get
right at the CAD step and expensive to redo later.

## Install

Requires **Windows**, an Ansys installation with SpaceClaim (2024 R2 tested),
and [`uv`](https://docs.astral.sh/uv/).

### Three ways to install

There are three supported ways to install this in DSH. A and B differ only in
**where the MCP server is loaded from**; C installs the launcher once and then
starts it from PATH, so **`uv` resolves nothing at launch**:

| | Option A: install from GitHub | Option B: install from a local checkout | Option C: install the launcher into PATH |
| --- | --- | --- | --- |
| Plugin package | `github:1622352030/ansys-agent-bridge` | `link:<path to your clone>` | same as A or B — C replaces only the MCP row |
| MCP launch source | `git+https://github.com/…#subdirectory=spaceclaim/python` | `<clone>/spaceclaim/python` | the installed console script `ansys-bridge-mcp` (no `uv`, no path) |
| Network needed per launch | **yes** (a `git fetch` first) | **no** GitHub fetch; the PyPI index may be consulted when the environment is (re)materialised | **only once, at install time** |
| Dependency resolution per launch | every launch | every launch, and any change under `<clone>/spaceclaim/python` re-materialises the environment | none — resolved once by `uv tool install` |
| No VPN / GitHub unreachable | ⚠ fails, and can take the whole desktop app down | fine | fine |
| After editing this repo | reinstall required | takes effect immediately | takes effect immediately, with no re-resolution and no rebuild |
| Tracks upstream automatically | yes | no | no |
| Undoing it | `dsh plugin … remove` | `dsh plugin … remove` | `uv tool uninstall ansys-agent-bridge` |

**If you are behind an unreliable route to GitHub, use Option B.** When Option A's
`git fetch` fails, the bundled `failOnStartupError: false` hides the error, and the
automatic reconnect loop rebuilds the environment and re-imports
`ansys.geometry.core` / `ansys.fluent.core` over and over, saturating DSH's
single-threaded host event loop — every session history sits at "loading" and the
desktop stops responding. The plugin manager then decides that enabling it "would
persist a state that crashes at boot" and **latches it as disabled**, after which
the UI switch will not bring it back. Mechanism, diagnostics and the unlock
procedure: [TROUBLESHOOTING.md](./TROUBLESHOOTING.md).

### Option A: install from GitHub

```sh
dsh plugin --profile web add ansys-agent-bridge
```

That installs the package, which declares `dsh.bundle`, so the loader applies
its `cordis.patch.yml`: one MCP client layer registering the `ansys` server.
Restart the profile and call `ansys_bridge_doctor`.

Turning it off again is the same in reverse:

```sh
dsh plugin --profile web remove ansys-agent-bridge
```

The MCP row launches the server with `uv tool run --from <this repo>`, so no
clone and no virtualenv is needed on the target machine — but the **first**
call pays for resolving and building `ansys-geometry-core`. If you keep a local
checkout and want a warm environment instead, use **Option B** below — note the
path is `<clone>/spaceclaim/python` (the Python package lives under `spaceclaim/`);
the older `<clone>/python` form no longer applies.

### Option B: install from a local checkout

Two steps: point the plugin at your local directory, then override the MCP row's
launch source with a `--patch` overlay.

```sh
# 1) point the plugin package at your local clone
dsh plugin --profile web add link:C:/path/to/ansys-agent-bridge
```

```yaml
# 2) restate this row in your own --patch layer
#    (an id-targeted override replaces the whole `config`)
#    %APPDATA%\dsh-desktop\harness\profiles\web\cordis.patch.yml
- id: mcp-ansys
  name: '@deepseek-ai/dsh-mcp-client'
  config:
    serverName: ansys
    transport: stdio
    command: uv
    args:
      - 'tool'
      - 'run'
      - '--quiet'
      - '--from'
      - 'C:/path/to/ansys-agent-bridge/spaceclaim/python'
      - 'ansys-bridge-mcp'
    env:
      PYTHONIOENCODING: 'utf-8'
      ANSYS_BRIDGE_LOG_LEVEL: 'WARNING'
    toolCallTimeoutMs: 900000
    failOnStartupError: false
```

Restart DSH for it to take effect.

### Option C: install the launcher into PATH

Option B still hands every launch to `uv tool run`, which resolves dependencies and
re-materialises the isolated environment each time — and redoes that work from
scratch whenever anything under `<clone>/spaceclaim/python` changes (editing
`src/…`, for one). Install the launcher **once** instead, and let DSH start a plain
command:

```sh
uv tool install --editable --from <clone>/spaceclaim/python ansys-agent-bridge
```

> ✅ Re-verified 2026-10-17: `uv tool list` reports `ansys-agent-bridge v0.1.0` with **both**
> console scripts (`ansys-bridge-doctor`, `ansys-bridge-mcp`), and `ansys-bridge-mcp`
> completes a real MCP `initialize` handshake over stdin (`protocolVersion` plus the full
> tool list on stdout) — the install is not just on paper.

The positional argument is the **package name** (`[project].name` in
`pyproject.toml`), **not** the command name. `uv` rejects the command-name form
itself, with no room for interpretation:
`error: Package name (ansys-agent-bridge) provided with --from does not match install request (ansys-bridge-mcp)`.

That installs an editable tool environment and puts the console scripts on PATH —
on Windows `%USERPROFILE%\.local\bin\ansys-bridge-mcp.exe`, and `uv tool dir --bin`
prints the exact directory. If that directory is on PATH, the DSH override becomes a
**bare command name, with no path and no `uv`**:

```yaml
# restate this row in your own --patch layer
# %APPDATA%\dsh-desktop\harness\profiles\web\cordis.patch.yml
- id: mcp-ansys
  name: '@deepseek-ai/dsh-mcp-client'
  config:
    serverName: ansys
    transport: stdio
    command: ansys-bridge-mcp
    args: []
    env:
      PYTHONIOENCODING: 'utf-8'
      ANSYS_BRIDGE_LOG_LEVEL: 'WARNING'
    toolCallTimeoutMs: 900000
    failOnStartupError: false
```

Restart DSH for it to take effect. Editing this repo keeps working immediately:
`--editable` points the installed environment at `<clone>/spaceclaim/python/src`, so
`uv`'s own help describes it as "changes in the package's source directory are
reflected without reinstallation" — nothing is resolved, rebuilt or fetched on the
next start. Undo the install with:

```sh
uv tool uninstall ansys-agent-bridge
```

### Why the patch has neither `!!js` nor an absolute path

That is deliberate: the harness CLI (0.1.1-rc.1) evaluates `!!js` in a scope without
`createRequire` and does not await the result, so an expression using either boots on
the Desktop app (0.1.5-rc.2) and leaves the CLI with a profile that will not start.
Both were tried and both broke it; `command: uv` needs neither. Override that one
line if `uv` is not on the PATH the harness was launched with:

```yaml
    command: C:/Users/you/.local/bin/uv.exe
```

### The bundled skill

The same `!!js` restriction is why `spaceclaim/skills/ansys-spaceclaim/` is **not** wired up
by the patch. Registering it needs a path resolved at load time, which needs one
of the two constructs above. Add it explicitly instead, in your profile's own
`cordis.patch.yml` (next to the bundle's) — this runs inside the host, where
`dshHomePath` is always available and no module resolution is involved:

```sh
mkdir -p "$DSH_HOME/skills"                      # or %APPDATA%\dsh-desktop\harness\skills
cp -r <repo>/spaceclaim/skills/ansys-spaceclaim "$DSH_HOME/skills/"
```

`$DSH_HOME/skills` is one of the host's default skill roots, so nothing else is
needed. Without it the MCP tools still work; what you lose is the measured
operating notes and the trap list.

### Any other MCP client

The server is a plain stdio MCP server, so it is not tied to DSH. Generate the
block for your client:

```sh
uvx --from "git+https://github.com/1622352030/ansys-agent-bridge#subdirectory=spaceclaim/python" \
    ansys-bridge-doctor --config claude    # also: cursor, vscode, dsh
```

`claude` and `cursor` emit an `mcpServers` block, `vscode` emits a `servers`
block with an explicit `"type": "stdio"`, and `dsh` emits the `insert` patch
entry for a profile's `cordis.patch.yml`. Paste the result into the client's
configuration.

For a client whose schema you would rather write by hand, the command is:

```jsonc
{
  "mcpServers": {
    "ansys": {
      "command": "uv",
      "args": [
        "tool", "run", "--quiet",
        "--from", "git+https://github.com/1622352030/ansys-agent-bridge#subdirectory=spaceclaim/python",
        "ansys-bridge-mcp"
      ]
    }
  }
}
```

Use `uv tool run`, not `uv run`: measured against uv 0.11.29, `uv run` rejects
`--from` with `unexpected argument '--from' found`, and the MCP client shows
only `Connection closed`.

## Check the environment first

```sh
uvx --from "git+https://github.com/1622352030/ansys-agent-bridge#subdirectory=spaceclaim/python" \
    ansys-bridge-doctor
```

```
ansys-agent-bridge 0.1.0
  python        3.13.4  C:\...\python.exe
  platform      Windows-11-10.0.26100-SP0

ANSYS releases detected (AWP_ROOT* and standard install roots):
  242          C:\Program Files\ANSYS Inc\v242
                 SpaceClaim.exe: C:\Program Files\ANSYS Inc\v242\scdm\SpaceClaim.exe

  Fluent root   C:\Program Files\ANSYS Inc\v242\fluent

Python packages:
  ansys.geometry.core      0.17.2
  ansys.fluent.core        0.42.1
  mcp                      1.28.1

Ready:
  server       yes
  spaceclaim   yes
  fluent       yes
```

Add `--json` for the machine-readable report.

## Environment variables

| Variable | Default | Effect |
|---|---|---|
| `ANSYS_BRIDGE_UV` | — | Not read by the patch (which has no `!!js`). Override the `command:` line with a `--patch` overlay if `uv` is not on PATH. |
| `ANSYS_BRIDGE_PRELOAD` | `1` | Import the Ansys clients at start-up. Set `0` for a fast start; the first SpaceClaim call then pays that import. |
| `ANSYS_BRIDGE_TRANSPORT` | `stdio` | `stdio`, `sse`, or `streamable-http`. |
| `ANSYS_BRIDGE_LOG_LEVEL` | `WARNING` | FastMCP request logging. `INFO` logs one line per request. |

### Why start-up preloads, and why tools are serialised

FastMCP runs a synchronous tool **on the event loop thread**
(`mcp/server/fastmcp/utilities/func_metadata.py`: `return fn(**args)` — there is
no `to_thread`). Lazy-importing `ansys.geometry.core` from inside a tool call
was measured wedging the whole process inside numpy's C extension
`create_module`, **with no exception and no timeout** — the client sees a tool
call that simply never returns:

```
_call_with_frames_removed (<frozen importlib._bootstrap>:488)
create_module (<frozen importlib._bootstrap_external>:1321)
<module> (numpy\_core\multiarray.py:11)
...
start (ansys_bridge_mcp\scdm.py)
scdm_session_start (ansys_bridge_mcp\server.py)
_handle_message (mcp\server\lowlevel\server.py)
```

The identical import finishes in **0.9 s** at process start, so `main()`
imports numpy and both Ansys clients before accepting a request. Stacks were
captured with `py-spy dump`.

The same fact means **long tools block the server**: a 30-second SpaceClaim
start holds the event loop, so a concurrent call queues behind it. That is
acceptable here — SpaceClaim mutates one design, and serialising is what you
want — but it is why `toolCallTimeoutMs` in the DSH patch is 900 s rather than
the 60 s default.

## Safety

- **Never open a file a SpaceClaim GUI has open, and never write to a source
  model.** Open, operate, export to a new path.
- A session holds a licence; call `scdm_session_close`.
- Package inspection is metadata-only. An earlier version imported
  `ansys.fluent.core` inside a tool call to read its version; that library
  prints during import, which corrupted the stdio JSON-RPC stream and killed
  the session mid-call. `package_version()` now reads metadata without
  importing.

## Layout

The repository is split by product domain, strictly. SpaceClaim material and
Fluent material never share a directory, down to the test scripts:

```
package.json            DSH bundle manifest (`dsh.bundle.patch`) + npm entry
cordis.patch.yml        the bundle's patch layer
TROUBLESHOOTING.md      desktop install / hang / MCP-will-not-re-enable, and how to unlock
screenshots.json
tools/verify-patch.mjs             offline check of cordis.patch.yml

spaceclaim/
  python/               the MCP server (uv/pip-installable, src layout)
  skills/ansys-spaceclaim/SKILL.md measured operating notes and traps
  docs/feature-coverage.md         implementation vs. the official API
  docs/verification.md             what was verified, and the bugs found
  dev/tests|logs|evidence|models|scratch

fluent/
  docs/mcp-audit.md                audit of the official ansys-fluent-mcp
  dev/tests|logs|evidence|scratch
  (no code yet -- the Fluent side currently uses the official package)
```

`dev/` holds development-time material and stays inside the repository on
purpose, so nothing is ever written into a user's model directory. Transcripts,
export dumps and the copied test models are gitignored; the test scripts and the
API inventories are kept.

The root keeps only what must be at the root: npm reads `package.json` there, and
`dsh.bundle.patch` resolves `./cordis.patch.yml` relative to it.

## What is and is not implemented

[`spaceclaim/docs/feature-coverage.md`](spaceclaim/docs/feature-coverage.md) is the item-by-item
comparison against the official API: every capability domain, whether it is
implemented, and — for the gaps — why. It is not a list of what the server does;
it is the list a reader needs to find what it does not do.

The headline is that **the official client declares far more than this release
can run**. Of 286 public methods carrying a `@min_backend_version` gate, only
**15 are callable on 24R2**; the other 271 need 25.1 through 27.1. That includes
all 44 `GeometryCommands` modelling methods. The comparison therefore filters by
version first, which is what separates a real gap from a method that would only
raise `GeometryRuntimeError`.

Two consequences worth knowing before you plan work:

- **Geometric inspection is now the headline feature.** Eight `RepairTools.find_*`
  methods carry no version gate at all, so they work on 24R2, and all eight are
  behind `scdm_inspect_geometry`. On the assembly whose meshing failed, that tool
  names the cause: two pairs of coincident faces, one on `stator` and one on
  `pip` in each pair, with identical areas. Replacing "the mesh failed" with
  "these two faces are stacked" is the whole point.
- **An external flow enclosure cannot be built through this API.** The three
  `create_*_enclosure` methods need 26.1.0. For external-flow CFD, build the
  domain in the SpaceClaim UI or use Fluent Meshing's enclosure instead.


## Development

```sh
node tools/verify-patch.mjs        # no profile needed
uv run --directory python pytest -q
```

`verify-patch.mjs` evaluates any `!!js` expression in the patch inside a bare
`with (ctx) { eval(expr) }` scope — exactly what the CLI builds — and refuses a
promise result, so a patch that would only boot on the newer harness fails here
instead of on a user's machine. It also checks that the console script the patch
launches is the one `pyproject.toml` declares.

## Licence

MIT. See [LICENSE](LICENSE).