Setup notes and hard-won API gotchas for driving Autodesk Fusion 360 from Claude Code via the fusionMCP server. Started while modelling the carpi-2din 2DIN enclosure; every model since has added to it — see Provenance.

Server Setup

Installed at user scope.

The /mcp path is mandatory

URL: http://127.0.0.1:27182/mcp

The bare host http://127.0.0.1:27182 returns 404. If the server appears “installed but dead”, check the path before anything else.

Fusion 360 must be running with a document open for the script host to have an activeProduct.

Build Geometry with TemporaryBRepManager, Not Sketches

The core pattern

Sketch + extrude is fragile in scripts because profile selection is index-based and silently shifts when geometry changes. Build solids directly instead.

tbm = adsk.fusion.TemporaryBRepManager.get()
 
box = tbm.createBox(adsk.core.OrientedBoundingBox3D.create(...))
cyl = tbm.createCylinderOrCone(...)
tbm.booleanOperation(box, cyl, adsk.fusion.BooleanTypes.DifferenceBooleanType)
 
base = root.features.baseFeatures.add()
base.startEdit()
root.bRepBodies.add(box, base)
base.finishEdit()

Key APIs:

  • createBox(OrientedBoundingBox3D) — boxes
  • createCylinderOrCone(...) — bosses, holes, pins
  • booleanOperation(target, tool, type) — union / difference / intersect
  • BaseFeature + startEdit() / bRepBodies.add(body, baseFeature) / finishEdit() — how temporary bodies become real, timeline-visible bodies

This is dramatically more robust than sketch-driven modelling when the script is generated or edited by an agent.

Gotchas

1. Units are centimetres

The whole Fusion API works in cm. Every millimetre value from a spec must be divided by 10 before it reaches the API. Write a single mm() helper and route everything through it — mixed units are the most common silent failure.

def mm(v):
    return v / 10.0

2. Design.cast(app.activeProduct) can return None

Reliable workaround

In the MCP script host, adsk.fusion.Design.cast(app.activeProduct) returned None even though activeProduct genuinely WAS a Design.

Use the product-type lookup instead:

design = app.activeDocument.products.itemByProductType('DesignProductType')

This works consistently where cast() does not.

3. Fusion is Z-up

Build with X = width, Y = depth, Z = height. Get this wrong and the model stands on its face in the viewport — technically correct geometry, useless for review screenshots and for print orientation reasoning.

Cheapest fix

If the axes are already wrong throughout the build code, remap inside the point/coordinate helper rather than rewriting every call site. One function change vs. hundreds of edits.

4. Always fit() before a screenshot

app.activeViewport.fit()

Without this the capture is zoomed into nothing useful — the camera keeps whatever state it had, which after a script run is usually meaningless. Call fit() immediately before any screenshot tool invocation.

5. CombineFeatures silently no-ops on BaseFeature bodies

Fails silently — no exception, no error return

combineFeatures.createInput(target, tools) with a JoinFeatureOperation runs, adds a Combine1 entry to the timeline, and changes nothing. Body count and volumes are identical afterward. Nothing is raised and nothing is returned to indicate failure.

Workaround — do the merge yourself in temporary BRep space:

  1. tbm.copy(body) every body you want to merge into temporary BRep bodies.
  2. tbm.booleanOperation(...) them together.
  3. Delete the original features and bodies (see gotcha 6 for the correct deletion order).
  4. Re-add the merged result in a fresh BaseFeature.

Verification rule

Never trust that a Combine ran. Always re-read body volumes (and body count) afterward and assert they changed as expected.

6. TimelineObject has no deleteMe()

Calling it raises AttributeError. Delete through the typed feature collections instead, downstream-first:

combineFeatures → extrudeFeatures → sketches → baseFeatures
while collection.count:
    collection.item(0).deleteMe()

Do not iterate a snapshot list

Building a list of features and then deleting them invalidates the remaining handles: RuntimeError 4: An API Object refers to a deleted Object

This happens because deleting one BaseFeature body can take its siblings with it. Always re-read item(0) from the live collection each pass.

7. Body renames — and appearances — right after finishEdit() do not stick

Names assigned immediately after baseFeature.finishEdit() were later read back as Body3..Body7 instead of the assigned names.

Fix: rename in a separate step. Re-read the bodies, match each one by boundingBox position, assign the name, then verify by reading the names back.

Confirmed again, and it is broader than names

The Geo Tracker body model (33 bodies) hit this a second time and established that appearances behave identically. Body names and body.appearance assigned in the same script execution as finishEdit() silently do not stick — no exception, no error return, the values are simply absent afterwards.

Both must be applied in a separate script execution. With many similarly-shaped bodies, matching by creation order is more reliable than matching by bounding box (mirrored L/R pairs have near-identical boxes).

Nuanced by gotcha 24

The CarBox mockup did succeed in naming and colouring bodies in the same execution as finishEdit() — but only by re-reading root.bRepBodies and matching by index/name, never by holding on to the proxies bRepBodies.add() returned. If a same-execution rename must work, that is the way; a separate execution remains the safer default.

8. Cutting text through a plate creates disconnected lumps

A through-cut of the word JEEP leaves the counter (the enclosed bowl) of the P as a loose island. It appears as a second body named <name> (1).

Detection

After any text or closed-profile cut, check both body.lumps.count and the body list. Enclosed glyph counters (A B D O P Q R, digits 0 4 6 8 9) are the usual culprits.

Fix with stencil-style tie bars:

  1. Read the island’s boundingBox.
  2. Build two bars that overlap the island by ~5 mm and extend past the surrounding glyph strokes into solid material.
  3. Union island + bars into the parent — via tbm, per gotcha 5, since CombineFeatures will silently do nothing.

9. Text meant to be read from below must be sketched on the bottom face

For skid plates, undertrays, and anything else read from underneath: sketch on the part’s bottom FACE, not the XY construction plane. Sketching on XY gives text that reads correctly from the top and mirrored from below.

When sketching on a face, convert model-space corner points with:

sketch.modelToSketchSpace(point)

Recompute min/max after conversion

The axis flip involved in a bottom-face sketch plane can swap min and max. Recompute them on the converted points before passing them to setAsMultiLine, or the text box comes out inverted or zero-sized.

Use setTwoSidesDistanceExtent for the cut so it goes through regardless of which way the face normal points.

Face normals are not a reliable bottom-face test

Plane.cast(face.geometry).normal returned +Z for a face on the underside. Normal sign cannot be used to identify a bottom face — test the face’s bounding box position instead.

10. Sequential cuts UNION — they can never express an intersection

The trap

Each tbm.booleanOperation(target, tool, Difference) removes its own region. Run several in sequence and the removed regions union together — you get “cut by A or B”, never “cut by A and B”. Any shape whose boundary is the intersection of an angled plane with axis-aligned planes is unreachable this way, no matter how the cuts are ordered.

Fix: compose the cutting TOOL first, then cut once.

  1. Build a plain box covering the full region you want gone.
  2. Subtract the angled half-space from that box.
  3. Use the resulting solid as the single cut tool against the part.

This came up on the Geo Tracker side windows, whose front edge follows the 45° A-pillar rake while the other three edges are axis-aligned. Three sequential cuts produced a window that ate into the A-pillar; one composed tool produced the correct shape.

Generalisation

Whenever you catch yourself writing “cut this, then cut that, then cut that” and the result is too big — you wanted an intersection. Move the boolean work into tool-space.

11. OrientedBoundingBox3D.create throws on a negative length

RuntimeError 3: invalid argument length

The lengths passed to create must be positive. This is trivially easy to violate when extents are written relative to a mirror sign:

box(sx * 380, sx * 690, ...)   # fine when sx = +1, throws when sx = -1

Every mirrored left-hand body flips the pair and the second value becomes the smaller one.

Fix once, in the helper

Sort the extents inside the box helper rather than at each call site:

lo, hi = sorted((a, b))

Same reasoning as gotcha 3 — fix the coordinate helper, not the hundreds of call sites.

12. userParameters.add() with an empty units string makes a UNITLESS parameter

The error points at the extrude, not at the parameter

RuntimeError: 3 : invalid expression

A parameter created with '' as its units argument is unitless. It looks fine in the Parameters dialog. But any extrude that references it by name then fails with the above — and the traceback points at the extrude, so you go hunting in the wrong file.

up.add('wall', adsk.core.ValueInput.createByReal(0.3), 'mm', 'wall thickness')
#                                                       ^^^^ not ''

Always pass 'mm' (or the intended unit). This is cheap to get wrong on a script that defines a dozen driving parameters up front.

13. The root component cannot be renamed

RuntimeError: 3 : root component name cannot be changed

root.name = 'KrakenPi_CarBox' throws. Name the document or the bodies instead. There is no workaround; just do not try.

14. The point-containment enum is PointOutsidePointContainment

The plausible-looking name is wrong:

adsk.fusion.PointContainment.PointOutsideBRepBodyContainment   # ✗ AttributeError
adsk.fusion.PointContainment.PointOutsidePointContainment      # ✓

Worth pinning because pointContainment() is the single most useful verification primitive in the API — see gotcha 20.

15. Construction plane offset direction is not predictable from the sign

Do not reason about it — measure it

A createByOffset plane with a given signed distance does not reliably land on the side you expect. The reference plane’s own orientation participates, and it is not obvious from the script.

Reliable pattern — create, read back, retry negated:

p = planes.add(offset_input(base, d))
if wrong_side(p.geometry.origin):
    p.deleteMe()
    p = planes.add(offset_input(base, -d))

Read plane.geometry.origin and compare it against where you actually wanted the plane. This is three lines and removes a whole class of silently-mirrored features.

16. Use sketch.modelToSketchSpace() on every non-XY plane

Generalises gotcha 9. The moment a sketch is on anything other than the XY plane — a wall, an offset plane, a face — stop guessing which local axis is which and convert model-space points:

sk.sketchCurves.sketchLines.addTwoPointRectangle(
    sk.modelToSketchSpace(adsk.core.Point3D.create(x0, y0, z)),
    sk.modelToSketchSpace(adsk.core.Point3D.create(x1, y1, z)))

Guessing costs a rebuild cycle every time and the mistake is invisible in the parameter list. Converting costs nothing.

17. A counterbore sketched on the part’s bottom plane bores straight through

Symptom: the counterbore is a through-hole

Sketch a 6.2 mm circle on the lid’s bottom plane and extrude it “the lid thickness” as a cut, and you get a 6.2 mm hole through the whole lid — not a 1.5 mm-deep recess with a floor.

Sketch on a plane offset from the TOP face by the counterbore depth instead, and cut downward by that depth:

cb = plane_at(xy, 'z', BOX_H + LID_T - LID_CBORE_H, 'cbore_plane')

Then assert the counterbore floor is solid (gotcha 20) — this failure mode is completely invisible in a render, because you are looking down the hole either way.

18. Build obround / stadium profiles as a rectangle + two circles

Do not hunt for a slot API. Sketch an overlapping rectangle and two circles, then extrude every profile in the sketch — Fusion unions the overlapping regions on its own:

def stadium(sk, cx, cy, minor, major):
    r = minor / 2.0
    rect(sk, cx - major/2 + r, cy - r, cx + major/2 - r, cy + r)
    circ(sk, cx - major/2 + r, cy, minor)
    circ(sk, cx + major/2 - r, cy, minor)

This is how the 2.8 × 6.8 mm slotted Pi mounts, the 3.4 × 6.7 mm wire entry and the 5.5 × 12 mm mount-ear slots in carbox-enclosure-geometry-spec are made.

19. MCP calls time out on long operations — but the operation still completes

Re-running a "failed" build can double-apply it

The Fusion MCP call returns a timeout on long scripts while Fusion keeps going and finishes the work. Treating the timeout as a failure and re-running is how you get duplicated bodies or a half-wiped design.

Always re-query state before concluding anything: read root.bRepBodies.count, the timeline, exported file mtimes — then decide. Never infer failure from the transport.

20. Verify with pointContainment() assertions and line scans — never screenshots

A render will lie to you about an enclosure

Looking into an open box, the camera sees straight through a near-wall opening to the far wall. A hole in the wrong wall, a hole 2 mm off, a “counterbore” that is really a through-hole, and a slot that is not actually slotted all look correct in a capture.

Assert instead. body.pointContainment(Point3D) against PointOutsidePointContainment gives a hard yes/no at a coordinate:

def is_open(bd, x, y, z):
    return bd.pointContainment(
        adsk.core.Point3D.create(x*MM, y*MM, z*MM)) == OUTSIDE

Negative assertions are the valuable half

Asserting an opening is open only proves you cut something somewhere. The checks that actually catch bugs are the ones asserting material is still solid:

  • the wall between two adjacent SMA slots
  • the 0.3 mm web between a switch cutout and its screw hole
  • the body of a screw post, right beside its pilot hole
  • the floor of a counterbore

Probe slots off-centre (x + 2) to prove they really are slotted and not round. Run a dense line scan along an axis to recover an opening’s true position and width rather than trusting the number you typed.

The CarBox build runs 102 such checks inside the build script itself, plus a 1 260-point corridor line scan, so a bad CONFIG edit fails loudly at rebuild time instead of at the printer. See gotcha 26 for why the asserts alone were not enough.

21. Appearances live in the Fusion Appearance Library, not in a material library

The silent failure: nothing throws, the bodies just stay default grey

materialLibrary.appearances on a material library is empty. itemByName(...) returns None, so addByCopy never runs, so no appearance is ever assigned — and no exception is raised anywhere. You look at a render full of default-coloured bodies and go hunting in the wrong place.

Appearances are in a separate library with its own name:

_lib  = app.materialLibraries.itemByName('Fusion Appearance Library')   # not a material lib
_base = _lib.appearances.itemByName('ABS (White)') if _lib else None
 
ap = des.appearances.itemByName(name) or des.appearances.addByCopy(_base, name)
for i in range(ap.appearanceProperties.count):
    prop = ap.appearanceProperties.item(i)
    if prop.objectType == adsk.core.ColorProperty.classType():
        prop.value = adsk.core.Color.create(r, g, b, 255)

Two halves, both required:

  1. Copy a base appearance into the document’s design.appearances — you cannot colour a library appearance in place.
  2. Set every property whose objectType == ColorProperty.classType(). A single “the colour property” does not exist; an appearance can carry several and setting only the first leaves the body looking wrong.

22. A Part Design document can contain only ONE component

RuntimeError: Part Design documents can only contain one component

root.occurrences.addNewComponent(...) throws in a Part Design document. There is no flag to relax it.

Put helper bodies in the root and namespace them with a prefix

Preview, mockup and scaffolding solids go in root.bRepBodies alongside the real geometry, named MOCK_* / WIRE_* so they are trivially filterable:

for b in (floor, frame, lid):          # explicit — never `for b in root.bRepBodies`
    export(b)

Combine this with building the preview bodies after the export block so they cannot leak into an STL even by accident. See carbox-enclosure-geometry-spec.

23. TemporaryBRepManager is right for decoration — but ~300 unions takes minutes

For non-parametric geometry (mockups, previews, visual-only solids), createBox / createCylinderOrCone / createSphere + booleanOperation, committed through one BaseFeature, is far simpler than sketch + sweep. No paths, no profiles, no sweep-fails-on-tight-radius.

A cable run reads properly as one smooth body if you union a chain of cylinders and drop a sphere at each joint to fill the mitre:

for i in range(len(path) - 1):
    seg = tbm.createCylinderOrCone(pt(path[i]), r, pt(path[i+1]), r)
    solid = seg if solid is None else (tbm.booleanOperation(solid, seg, UNION) and solid)
for p in path[1:-1]:
    tbm.booleanOperation(solid, tbm.createSphere(pt(p), r), UNION)

Round the corners first — replace each hard vertex with a quadratic bezier sampled at ~5 points — or the run looks like a stack of pipes rather than a bent cable.

The cost is time, and it collides with gotcha 19

~300 boolean unions takes minutes and will time out the MCP call while still completing. Budget for it and re-query body counts instead of re-running.

24. Naming/styling BaseFeature bodies: map by index or name, never by the returned proxy

Refines gotcha 7. Bodies added with bRepBodies.add(solid, baseFeature) inside a startEdit/finishEdit block can be named and styled after finishEdit() — but the proxy objects returned by add() are not reliable afterwards.

Re-read the collection and match positionally:

bf.finishEdit()
made = [b for b in root.bRepBodies if b.name not in ('floor', 'frame', 'lid')]
for b, (nm, solid, rgb) in zip(made, [p for p in parts if p[1]]):
    b.name = nm
    b.appearance = appearance('CARBOX_' + nm, rgb)

Filtering out the already-named real bodies is what makes the index alignment safe. Wrap each assignment in try/except and print what failed — some bodies accept a name and reject an appearance.

25. sketchTexts.createInput2 + setAsMultiLine for annotation — size the box generously

Sketch text is the cheap way to label a mockup so a render is self-describing.

ti = lsk.sketchTexts.createInput2(txt, h * MM)
ti.setAsMultiLine(
    adsk.core.Point3D.create(x * MM, y * MM, 0),
    adsk.core.Point3D.create((x + len(txt) * h * 0.85) * MM, (y - h * 1.8) * MM, 0),
    adsk.core.HorizontalAlignments.LeftHorizontalAlignment,
    adsk.core.VerticalAlignments.TopVerticalAlignment, 0)
lsk.sketchTexts.add(ti)

Undersize the diagonal and the text overflows its own rectangle

setAsMultiLine takes a corner and a diagonal corner. There is no autosize. len(text) * height * 0.85 is a working width heuristic; be generous — an oversized box is invisible, an undersized one wraps or clips.

Put the labels on their own construction plane above the part and hide every other sketch, so the annotated view is one visibility toggle away:

for s in root.sketches:
    s.isVisible = (s.name == 'COMPONENT_LABELS')

26. A spot check is not a scan — line-scan functional corridors

The failure this actually caused

In the CarBox, spot-check assertions on the back cable channel all passed. The channel was nonetheless blocked — the socket ribs walled it off and two lid posts stood in it, so the SDR’s power and data leads had no route. It was found by building a wiring mockup, not by the asserts.

Point checks prove a point. They do not prove a path. Anything a cable, a hand, a tool or airflow has to travel through needs a swept scan, and the build should fail on it:

blocked = []
for dy in (-2.0, 0.0, 2.0):                 # width of the corridor, not just its axis
    x = 40.0
    while x <= 250.0:
        if not is_open(frame, x, CABLE_Y + dy, probe_z):
            blocked.append((round(x, 1), CABLE_Y + dy))
        x += 0.5
chk('corridor clear (%d blocked pts)' % len(blocked), not blocked)

Two details that matter:

  • Scan at more than one offset. A single centreline scan misses an obstruction that only intrudes into one side of the corridor.
  • Print the blocked coordinates, not just a pass/fail — the X values tell you which feature is in the way.

Build the mockup, then re-verify

Modelling the actual contents — components and their cabling — is a verification technique, not decoration. It surfaces clearance failures that no assertion you thought to write would have caught. Then encode what it found as a scan so it can never regress.

Two Patterns Worth Reusing

The build script IS the model

Rather than treating the Fusion document as the artifact, make a single Python file the source of truth: a CONFIG block of literals at the top, everything below derived, and the script wipes the active design and rebuilds it from nothing on every run.

Benefits: the design is diffable and reviewable as text, an agent can change a dimension without touching the UI, and there is no accumulated state for index-based selection to drift against.

The trade-off is real

Any manual edit made in the Fusion UI is destroyed on the next run. Say so at the top of the file.

Bound the wipe loop

deleteMe() returns a boolean and can return False without raising. A naive while collection.count: loop then spins forever. Bound it and fail loudly:

def wipe(get_collection, label):
    for _ in range(4000):
        coll = get_collection()
        if coll.count == 0:
            return
        if not coll.item(coll.count - 1).deleteMe():
            raise RuntimeError('could not clear existing %s' % label)
    raise RuntimeError('wipe of %s did not converge' % label)
 
wipe(lambda: root.features, 'features')
wipe(lambda: root.sketches, 'sketches')
wipe(lambda: root.constructionPlanes, 'construction planes')
for b in list(root.bRepBodies):
    b.deleteMe()

Order matters — features → sketches → construction planes → bodies (downstream-first, as in gotcha 6). Note this deletes from count - 1 rather than item(0); either works as long as the collection is re-read each pass.

When sketch + extrude IS the right call

Gotcha’s headline advice is to prefer TemporaryBRepManager over sketches. That holds when a script incrementally modifies an existing design, because profile selection is index-based and shifts silently.

It does not hold for a full wipe-and-rebuild script. There, sketch + extrude is fine — and better — provided you extrude every profile in the sketch instead of picking one by index. That also buys you free unioning of overlapping profiles (gotcha 18). The CarBox is built this way end to end.

Checklist for a New Fusion MCP Script

  1. Get the design via products.itemByProductType('DesignProductType'), not Design.cast.
  2. Define mm() and use it for every literal.
  3. Confirm the axis convention: X width, Y depth, Z height.
  4. Build with TemporaryBRepManager, combine with booleanOperation.
  5. Commit through a single BaseFeature (startEditbRepBodies.addfinishEdit).
  6. Merge bodies with tbm.copy + tbm.booleanOperationnever CombineFeatures on BaseFeature bodies.
  7. Rename bodies and set appearances in a separate script execution, matched by boundingBox or creation order, then verify.
  8. After any text/closed-profile cut, check body.lumps.count and the body list for orphan islands.
  9. Delete via typed collections downstream-first, using while collection.count: collection.item(0).deleteMe().
  10. Need an intersection of cutting planes? Compose the cut tool first, then cut once — sequential cuts union.
  11. Sort extents in the box helper so mirrored (negative-sign) bodies cannot produce a negative length.
  12. Verify every destructive or boolean step by re-reading volumes/body counts — several APIs fail silently.
  13. app.activeViewport.fit() before capturing.
  14. Creating driving parameters? Pass 'mm' as the units argument — '' makes a unitless parameter and every extrude that names it throws invalid expression.
  15. Do not try to rename the root component — it throws.
  16. Sketching on anything other than XY? Convert points with sketch.modelToSketchSpace(); never guess the local axes.
  17. Offset construction plane? Create it, read plane.geometry.origin, delete and retry negated if it landed wrong. The sign alone does not tell you.
  18. Counterbores go on a plane offset from the TOP face, not the bottom — a bottom-plane sketch bores through.
  19. Obround/stadium = rectangle + two circles, then extrude every profile in the sketch.
  20. A timed-out MCP call does not mean the operation failed — re-query state before re-running.
  21. Verify with pointContainment() assertions and line scans, not screenshots — and write negative assertions (webs, post bodies, counterbore floors are still solid). Renders see through openings and will lie.
  22. Colouring bodies? The base appearance is in 'Fusion Appearance Library', not a material library — and set every ColorProperty, not the first one.
  23. Do not call root.occurrences.addNewComponent in a Part Design document — one component only. Namespace helper bodies in the root instead.
  24. Export by explicit body list, never for b in root.bRepBodies — and build preview/mockup solids after the export block.
  25. Naming/styling BaseFeature bodies: re-read the collection and match by index or name; the proxies add() returned are not reliable.
  26. Anything a cable, hand, tool or airflow must pass through gets a swept line scan at several offsets, not a spot check — and the build fails on it. Build the mockup and its cabling; it is verification, not decoration.

Provenance

GotchasOrigin
1–4carpi-2din 2DIN enclosure
5–9offroad-armour — specifically the segmented wh-grand-cherokee-skid-plate-set
10–11, and the appearance half of 7kickfix — the 33-body geo-tracker-body-model-fusion
12–26, and both reusable patternskrakenpi-carbox — the KrakenSDR + Pi 5 car enclosure (carbox-enclosure-geometry-spec). 12–20 from the v1 one-piece model; 21–26 from the v2 three-part split, its wiring mockup and the cable-corridor collision it exposed

All of them are generic Fusion API/MCP behaviour, not model-specific.