Core & scripting
The core family is the entry point for every OCCTMCP session: execute_script builds or rebuilds the 3D scene by running Swift CAD code; the remaining tools read state back out (scene manifest, last script, exported files, server liveness) or surface the live tool catalog for LLM auto-discovery.
Tools
execute_script · get_script · get_scene · export_model · get_api_reference · ping
execute_script
Compile and run an arbitrary Swift CAD script via a cached SPM workspace. The script writes into a private stage directory, never the scene; when it succeeds, its manifest and BREP files replace the scene.
Server: Swift + Node. The stage directory, the failure checks and the promotion described here are the Swift server’s; the Node server runs occtkit against the scene directory as before.
Parameters
| name | type | required | description |
|---|---|---|---|
code | string | yes | Complete Swift source for main.swift. |
description | string | no | Short description of what this script creates. |
Returns: The updated scene manifest (body list, colors, materials) plus any build/compiler output. On compilation failure, returns the compiler diagnostics under a Script failed. prefix. A script that exits non-zero, calls ctx.fail(_:) (its message follows Reason:), writes a manifest that cannot be read, or lists a file it did not write, fails the call and leaves the scene as it was. A script that never calls ctx.emit() succeeds, says it wrote no manifest and changes nothing.
Example
// tool call arguments
{
"description": "10 mm steel cube",
"code": "import OCCTSwift\nimport ScriptHarness\n\nlet ctx = ScriptContext()\nlet C = ScriptContext.Colors.self\n\nguard let box = Shape.box(width: 10, height: 10, depth: 10) else {\n throw ScriptError.message(\"box failed\")\n}\n\ntry ctx.add(box, id: \"cube\", color: C.steel, name: \"Cube\")\ntry ctx.emit(description: \"10 mm steel cube\")\n"
}
// canonical script template
import OCCTSwift
import ScriptHarness
let ctx = ScriptContext()
let C = ScriptContext.Colors.self
// ... build geometry with the OCCTSwift API (Shape.box, .cylinder, booleans, etc.) ...
try ctx.add(shape, id: "part", color: C.steel, name: "My Part")
try ctx.emit(description: "what this model is")
// example result
{
"bodies": [
{ "id": "cube", "name": "Cube", "color": "steel", "brepFile": "cube.brep" }
],
"description": "10 mm steel cube"
}
Notes: The script’s ScriptContext writes to the directory named by OCCTSWIFT_OUTPUT_DIR, which OCCTMCP sets to a new directory under the system temp directory, outside the scene. A clean run is promoted by replacing the scene with what the script emitted (a script’s manifest has always meant “the scene as this script built it”): the new bodies’ files are copied in under names that no earlier file has, manifest.json is swapped under the manifest lock, and the files the previous manifest listed are then removed, so a failure before the swap leaves the old scene whole. Other files in the scene directory, annotations.json among them, are not touched; provenance.json is cleared, as clear_scene clears it. The stage is removed afterwards. The SPM workspace is ~/.occtmcp-cache/workspace, or the directory in OCCTMCP_SCRIPT_WORKSPACE when that is set. Cold start is ~60 s on the first call (full SPM build of OCCTSwift); subsequent calls are ~1–2 s incremental. Fallible OCCTSwift factories return optionals: unwrap with guard let, never force-unwrap. Boolean operators on Shape: a - b subtract, a + b union, a & b intersect. The manifest write triggers the OCCTSwiftViewport live 3D reload.
Drives: occtkit run <tempfile> (Node); the cached SPM workspace and ScriptHarness (Swift).
get_script
Return the source of the most recent Swift CAD script executed in this session.
Server: Swift + Node
No parameters.
Returns: The Swift source string of the last execute_script call in this session, or an error if no script has been run yet.
Example
// tool call arguments
{}
// example result
{
"source": "import OCCTSwift\nimport ScriptHarness\n\nlet ctx = ScriptContext()\n..."
}
get_scene
Read the current scene manifest (bodies, colors, materials).
Server: Swift + Node
No parameters.
Returns: The contents of manifest.json: the list of bodies currently in the scene, each with its id, name, color, material, and BREP file reference.
Example
// tool call arguments
{}
// example result
{
"bodies": [
{ "id": "part", "name": "My Part", "color": "steel", "brepFile": "part.brep" }
],
"description": "A simple test part"
}
export_model
List exported model files (BREP, STEP, STL, OBJ, IGES, glTF, JSON) from the current output directory.
Server: Swift + Node
No parameters.
Returns: A list of file paths for every recognised CAD export format found in the output directory. Does not trigger an export; use export_scene (scene-mutation family) to write new files.
Example
// tool call arguments
{}
// example result
{
"files": [
"/Users/me/Library/Mobile Documents/com~apple~CloudDocs/OCCTSwiftScripts/output/part.brep",
"/Users/me/Library/Mobile Documents/com~apple~CloudDocs/OCCTSwiftScripts/output/part.step"
]
}
get_api_reference
Returns a catalog of every MCP tool this server exposes (category=mcp_tools), or a pointer to OCCTSwift docs for the OCCT API categories.
Server: Swift + Node
Parameters
| name | type | required | description |
|---|---|---|---|
category | string | no | 'mcp_tools' for the live tool catalog; any other value returns a pointer to the OCCTSwift sources / docs. |
Returns: When category is "mcp_tools": the full JSON Schema of every registered tool, suitable for LLM auto-discovery. Otherwise: a pointer to OCCTSwift source documentation.
Example
// tool call arguments (request the live tool catalog)
{ "category": "mcp_tools" }
// example result (truncated)
{
"tools": [
{
"name": "execute_script",
"description": "Compile and run an arbitrary Swift CAD script …",
"inputSchema": { "type": "object", "properties": { "code": { "type": "string" } } }
}
]
}
Notes: Call this at session start with category: "mcp_tools" to let the LLM discover the full tool surface without relying on static documentation.
ping
Sanity-check tool: returns "pong" so callers can verify the OCCTMCP Swift server is alive.
Server: Swift only
No parameters.
Returns: The string "pong".
Example
// tool call arguments
{}
// example result
"pong"
Embedding: extra tools
A Swift host that links OCCTMCPCore can serve tools of its own beside OCCTMCP’s. Registering a handler on the returned Server replaces OCCTMCP’s ListTools and CallTool handlers, so pass the extra tools to the factory instead:
let server = await makeOCCTMCPServer(extraTools: [
ExtraTool(
tool: Tool(name: "get_scene_hierarchy", description: "...", inputSchema: .object([:])),
handler: { arguments in
.init(content: [.text(text: "...", annotations: nil, _meta: nil)], isError: false)
})
])
- Extra tools are listed after the built-ins and are dispatched before them.
- A name that matches a built-in replaces it: the built-in is dropped from
ListToolsand is never called, so the name appears exactly once. - If several extras share a name, the last one in the array wins.
- A handler that throws produces an error result (
isError: true); the server keeps running. get_api_referencewithcategory: "mcp_tools"lists the live catalog, so it includes the extra tools and omits any built-in they replaced.- With no extras (the default) the catalog and behaviour are unchanged.
What an extra tool can hold, add and report
A handler is a closure, so it can carry whatever the tool needs between calls. A pair of tools that share a ledger, a solve or a recipe the human is editing hold it the same way:
actor Ledger {
private var entries: [String] = []
func append(_ entry: String) { entries.append(entry) }
var all: [String] { entries }
}
let ledger = Ledger()
let extras = [
ExtraTool(tool: ledgerAddTool, handler: { args in
await ledger.append(args["entry"]?.stringValue ?? "")
return .init(content: [.text(text: "recorded", annotations: nil, _meta: nil)])
}),
ExtraTool(tool: ledgerListTool, handler: { _ in
.init(content: [.text(text: await ledger.all.joined(separator: ","), annotations: nil, _meta: nil)])
}),
]
- The state lives as long as the closure that holds it, so one
Ledgerper server is one session. Handlers run concurrently (the SDK handles each request on its own task), so guard shared state with an actor or a lock, as above. - A tool call is not retried and has no identity beyond its arguments: a handler that needs to tie one call to another does it through its own state or through the scene.
Adding a body to the scene. SceneBodies.add(shape:) writes a BREP body into the scene and returns the id it was given. It is the code read_brep and import_file use, so a body a handler adds is staged beside the manifest, moved into place under the manifest lock, pushed onto SceneHistory (so compare_versions can diff against it), and reported to the host’s manifestWriteObserver under the handler’s own tool name.
let id = try await SceneBodies.add(shape: solid, idPrefix: "profile", color: [0.8, 0.2, 0.2, 1])
// "profile", or "profile_1" if that id was taken. Built-in tools take it as `bodyId`.
- Pass
bodyIdto choose the id. A taken id throwsManifestError.bodyIdTakenand leaves the existing body alone. Without it the first freeidPrefix,idPrefix_1,idPrefix_2is used, chosen under the manifest lock, so concurrent adds with one prefix each get their own id. - A body’s file is
<id>.brep. An add whose file name another body already has, compared without regard to case because the scene may sit on a case-insensitive volume, throwsSceneBodiesError.fileInUseand overwrites nothing; a generated id skips such names.read_brepandimport_fileapply the same rule. - An id names the body’s file, so it must not be empty, start with a dot or contain
/; anything else throwsSceneBodiesError.invalidBodyIdbefore anything is written.read_brepandimport_filenow apply the same rule. allowInvalid: truewrites a shape that fails validation, asimport_filedoes for a mesh shell.- It does not check that the shape has an extent.
read_brepdoes, and a handler that reports a bounding box should checkshape.boundsfirst. - The body is always written as BREP. A handler that wants a
stepbody writes the file and the manifest entry itself, throughManifestStore.update.
Finding a body again. SceneBodies.body(withId:) returns the manifest entry and SceneBodies.shape(ofBody:) returns its shape, read the way a built-in reading tool reads it: the host’s own shape from bodyShapeProvider when it supplies one, else the body’s file read by its format, parsed afresh on each call. That is right for geometry: measure it, take a volume, tessellate it. It is not a way to resolve a selectionId or a GraphUID. Those resolve through the body’s retained lineage graph, a node index there is not a position in Shape.faces(), and a freshly parsed shape is not a node of that graph. A host that holds the shape its picks index into returns it from bodyShapeProvider, and the shape a handler reads here is then that same one. Errors are IntrospectionTools.ToolError (no scene, no such body, file missing) or BodyLoadError (unreadable file); a handler that lets one escape produces an isError result that names it.
Both resolve the scene directory from the calling task, so they must run on the tool call’s own task or a child of it. Task.detached drops the directory passed to outputDirectory: and falls back to the process default.
Progress. OCCTMCPContext.reportProgress(_:total:message:) sends notifications/progress for the running call when the client put a progressToken in the request’s _meta, and does nothing otherwise, so a handler calls it unconditionally:
for (i, step) in steps.enumerated() {
try Task.checkCancellation()
try await step.run()
await OCCTMCPContext.reportProgress(Double(i + 1), total: Double(steps.count), message: step.name)
}
Send values that rise with each call, and only while the handler is running: the spec ends a request’s progress when it completes. A notification that cannot be sent is dropped and never fails the call. The built-in tools do not report progress today; this is for handlers that run for seconds, such as a constraint solve.
Cancellation. When a client sends notifications/cancelled, the SDK cancels the Swift task running the handler. A handler awaiting a cancellable call (Task.sleep, a URLSession request) is interrupted at that await. Awaiting an actor’s method is not a cancellation point, and neither is a synchronous loop: check try Task.checkCancellation() between steps. Rethrow CancellationError rather than turning it into a result. A handler that ignores the cancellation runs to its end, because the server waits for it, but the server then sends no response for a request that was cancelled, as the MCP spec asks, whatever the handler returned. The host’s toolCallObserver still sees the call finish.
Embedding: promoting a script’s output
execute_script runs the script against a stage directory (see above) and promotes a clean run itself, by replacing the scene. A host that owns its scene store takes the last step instead:
let server = await makeOCCTMCPServer(
outputDirectory: sceneDirectory,
scriptStagePromoter: { stage, sceneDirectory in
// `stage` holds what the script wrote: manifest.json, the body files it lists, and any
// artefacts such as output.step. Put it into the scene the way the host does.
try await sceneStore.promote(stage: stage, into: sceneDirectory)
})
- The promoter is called only for a run that exited zero, left no
run-failed.jsonand wrote a manifest that parses and lists only plain file names the stage holds. A failed run, a run that wrote no manifest, and a bad manifest are reported to the agent without calling it, and the scene is left as it was. - With a promoter nothing is copied into the scene for it: the scene is the host’s to change. The prior scene is still pushed onto
SceneHistory, once the promoter has returned without throwing. - The stage is outside the scene directory by construction, so a host can refuse a stage inside its own scene. OCCTMCP removes the stage when the promoter returns, whether it returned or threw; a host that wants to keep any of it (a quarantined stage to retry, say) moves it first.
- A promoter that throws fails the call:
Script ran, but promoting its output into the scene failed: <reason>. What state it left the scene in is the host’s to know. ExecuteScriptTool.execute(code:...promoter:)takes the same closure for a host that wraps the tool; it defaults to the one given tomakeOCCTMCPServer, scoped to the tool call like the observers above.- The child is told
OCCTSWIFT_OUTPUT_DIR. OCCTMCP never exportsOCCTMCP_OUTPUT_DIR, which names the scene and which the harness does not read.
Embedding: observing tool calls and manifest writes
Two optional arguments to makeOCCTMCPServer let a host see what the built-in tools do:
let server = await makeOCCTMCPServer(
outputDirectory: sceneDirectory,
manifestWriteObserver: { bytes, tool in
// Under the manifest lock, before the atomic rename. `tool` is the running tool's name.
record(bytes, attributedTo: tool)
},
toolCallObserver: { event in
// `.started`, then `.finished(isError:)`, for each `tools/call`.
show(event.name, event.arguments, event.phase)
})
manifestWriteObserverruns insideManifestStorefor every write a tool makes, so a new mutating tool is covered without changes. Throwing aborts the write.OCCTMCPContext.currentToolholds the name; it is nil for a write made outside a tool call.toolCallObserverreceives events one at a time, in order, on its own task: a slow observer never delays a tool result. Events carry acallIDthat ties a start to its finish.- With neither set (the default) behaviour is unchanged.
Body formats, and supplying a body’s shape
A manifest body carries a format, and every tool that reads a body’s shape reads its file by that format: brep through Shape.loadBREP, step through the STEP reader, and iges, stl and obj as import_file does (one shared reader, so a body and an import of the same file agree). The value is matched case-insensitively and stp, igs and brp are accepted. STEP is read with a 1 mm target unit, as import_file reads it. A format outside that list is an error that names it, as is a file that does not parse:
Failed to read step body "valve" from /scene/valve.step: Failed to read /scene/valve.step: <status>
Body "valve" has format "glb", which this server cannot read. Supported formats: step, iges, obj, brep, stl.
Tools that write a body back write BREP and copy the input body’s format onto the result: transform_body, apply_feature, heal_shape and align_bodies overwrite the body’s own file, and boolean_op and mirror_or_pattern write a new file. A step body would be overwritten with BREP bytes or a BREP file would be labelled step, so these accept brep bodies only and refuse any other with This tool edits brep bodies only, leaving the file and the manifest as they were. The raw-path tools (graph_validate, graph_compact and the other brep_path tools) read BREP by construction.
A host that already holds a body’s shape can hand it to the tools, so a pick the host made and the entity a tool reads are the same one. A STEP assembly read twice can come back with a different face order or different GraphUIDs, and the host’s own copy is the one its picks index into:
let server = await makeOCCTMCPServer(
outputDirectory: sceneDirectory,
bodyShapeProvider: { body, outputDir in
// The shape the host loaded for this body, or nil to let the tool read the file.
hostScene.shape(forBodyId: body.id)
})
- The provider is consulted first, by every read of a body’s shape, including the history-aware reads behind
select_topology,get_selection,auto_dimension,select_by_featureandmeasure_distance. Returning nil falls through to the format dispatch above. Throwing fails the tool call, except in the few lookups that already treat an unreadable body as absent (remap_selection, thediff_overlaymarkers and the source side offind_correspondences), where it counts as unreadable. - The body’s file stays the manifest’s record of it, and most tools check it exists before asking the provider, so a host should still write the file. The provider replaces reading it.
- The retained lineage graph for a body is built from the provided shape and cached by that shape’s identity as well as by the body file’s fingerprint. A host that swaps the shape it holds without touching the file gets a rebuilt lineage, so a
selectionId’s index and uid stay those of the shape the host holds. A lookup the host declines reads the file, never the host’s old shape. - Editing tools never ask the provider: they start from the body’s file, and
align_bodieswithapply: truereads the file it is about to overwrite (a dry run reads like any other tool). A lineage built from the host’s shape is not continued into an edit, so an edit runs on the same input whichever tool ran before it, and aGraphUIDminted from a host pick does not survive the edit;remap_selectionthen resolves it by a lower rung. For the same reason a host-backed read of a body after an edit rebuilds its lineage from the host’s shape (a refreshed copy is a different shape from the edit’s output), which drops the history the edit recorded, so callremap_selectionbefore reading the body again. A host that supplies shapes for bodies those tools edit should refresh its copy when the manifest changes, as it would for any edit. - The registry keeps the host’s shape alive in the body’s lineage entry until that body is looked up again, renamed or cleared, so a host that drops a large body can find it retained.
- The provider is called synchronously, on whichever executor the tool is already running on: the main actor for
render_preview,pick_surface_point,align_bodiesand the mesh, heatmap, zone, fit and symmetry tools, and inside the history registry’s actor for the lineage reads (select_topology,get_selection,auto_dimension,select_by_feature,measure_distance). It must return without waiting on either: a hop to the main actor withDispatchQueue.main.syncdeadlocks in the main-actor tools, andMainActor.assumeIsolatedtraps in the rest. Keep the shapes in a lock-protected map and read that. The tool is handed the shape itself, uncopied, so the host also owns synchronisation of anything else that reads it while a tool runs. - A tool that tessellates a body (
generate_mesh,render_preview,pick_surface_pointand the mesh and deviation tools) meshes the provided shape itself, and OCCT keeps a triangulation on the shape it meshes. A host that keeps its own display triangulation on that shape can return a copy for those tools, at the cost of the shared identity above, or accept that they share it. - The provider is a task-local scoped to each tool call, like the observers above, so two servers in one process never see each other’s. It is not seen from a
Task.detached. - With no provider (the default) behaviour is unchanged for
brepbodies.
Embedding: committing the manifest
Every built-in tool that changes the scene commits manifest.json through ManifestStore.update, so a host that writes the manifest itself can take part in the same lock:
let store = ManifestStore(path: "\(sceneDirectory)/manifest.json")
let written = try store.update(willWrite: { bytes in
// The exact bytes about to land, under the lock, before the atomic rename.
digestOfOwnWrite = sha256(bytes)
}) { current in
// `current` is nil when the file is absent.
let current = try current.requiringScene()
return current.replacing(bodies: current.bodies.filter { $0.id != "old" })
}
updatetakes an advisoryflockonmanifest.lockin the scene directory (a separate file, because the atomic write replaces the manifest’s inode), reads, runs the transform, callswillWrite, writes atomically, and releases.ManifestStore.writetakes the same lock.- Throwing from the transform or from
willWriteaborts the write and leaves the file untouched. - A malformed
manifest.jsonthrowsManifestError.malformed, fromupdateand from every built-in tool, and is never overwritten. Tools no longer report it as “No scene loaded”. - The lock is not re-entrant: calling
updateorwriteon the same path from inside a closure waitslockTimeout(30 s by default) and throwsManifestError.lockTimeout. - The guarantee holds between processes that know the lock. An OCCTMCP or
occtkitthat does not can still lose updates.