Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

MCP server that gives LLMs the ability to author, inspect, and iterate on 3D CAD models with OpenCASCADE via the OCCTSwift family. Two implementations live side-by-side:

- **Swift** (`Sources/`, `Package.swift`): the **primary**, in-process server. Uses the official Swift MCP SDK, calls OCCTSwift / OCCTSwiftMesh / OCCTSwiftTools / OCCTSwiftAIS / DrawingComposer directly. 77 typed tools. macOS 15+.
- **Swift** (`Sources/`, `Package.swift`): the **primary**, in-process server. Uses the official Swift MCP SDK, calls OCCTSwift / OCCTSwiftMesh / OCCTSwiftTools / OCCTSwiftAIS / DrawingComposer directly. 79 typed tools. macOS 15+.
- **Node / TypeScript** (`src/`, `dist/`): the original implementation. Shells out to the `occtkit` CLI via `OCCTSwiftScripts`. 37 tools (the pre-v0.4 surface; selection / remap / annotations are Swift-only).

Both speak stdio MCP and read/write the same `manifest.json` + `annotations.json` files in the output directory. Pick whichever fits the host: the Swift binary eliminates JSONL marshalling and per-call subprocess spawn; the Node server runs anywhere a Node 18+ runtime exists, but needs `occtkit` on `$PATH`.
Expand Down Expand Up @@ -41,7 +41,7 @@ npm run test:integration # node:test end-to-end chain through occtkit (slow; ~3

`Sources/OCCTMCPCore/` (library) + `Sources/OCCTMCPServer/` (executable that connects stdio).

- `Server.swift`: `createServer()` factory: registers all 77 tools with their JSON Schemas, returns an `MCP.Server` ready to bind to a transport. Tests import `createServer()` to introspect the registry without binding stdio. The `get_api_reference` tool's `mcp_tools` category dumps the live registry as JSON Schema for LLM auto-discovery.
- `Server.swift`: `createServer()` factory: registers all 79 tools with their JSON Schemas, returns an `MCP.Server` ready to bind to a transport. Tests import `createServer()` to introspect the registry without binding stdio. The `get_api_reference` tool's `mcp_tools` category dumps the live registry as JSON Schema for LLM auto-discovery.
- `Tools/`: one file per tool family:
- `CoreTools.swift`: `get_scene`, `get_script`, `export_model`, `get_api_reference`
- `ExecuteScriptTool.swift`: `execute_script` (writes Swift to tempfile, `occtkit run` via the resolved binary, parses manifest)
Expand Down Expand Up @@ -77,6 +77,7 @@ npm run test:integration # node:test end-to-end chain through occtkit (slow; ~3
- `AnalysisTools.swift`: graph-level: `graph_validate`, `graph_compact`, `graph_dedup`, `graph_ml`, `feature_recognize`
- `SelectionTools.swift`: `select_topology`. #91: the index embedded in a `selectionId` (`sel:<bodyId>#face[<idx>]`) has to be a `BRepGraph` node index (that's what `RemapTools.remapViaHistory` feeds into `findDerivedOrSelf(of:)`), not a raw `Shape.faces()/.edges()/.vertices()` enumeration index. Those turned out NOT to be the same index space for edges/vertices (verified false on a plain box: `TopologyIdentityTests`; true only for faces, apparently by coincidence). `SelectionTools.graphIndex(for:kind:in:fallback:)` resolves the real graph index via `BRepGraph.findNode(for:)` for every kind. `edgeGeometryFields(edge:)` (#119) computes `endpoints`/`direction`/`circleCenter`/`radius`/`axis`/`startAngle`/`endAngle`, shared by `select_topology`'s edge anchors and `IntrospectionTools.queryTopology`'s edge results. GOTCHA pinned by `EdgeGeometryFieldsTests`: `edge.curve3D?.circleProperties` used to crash (SIGSEGV) when chained in one expression, since `CircleProperties` wraps the SAME native handle as its parent `Curve3D` WITHOUT retaining it, so the temporary `Curve3D` deallocates (releasing the handle) the instant the optional-chain expression finishes; fixed by binding `edge.curve3D` to a local `let` that outlives every use of `.circleProperties` on it
- `RemapTools.swift`: `remap_selection` (consults `HistoryRegistry`, falls back to centroid heuristic). The centroid-heuristic path also mints selectionIds (via `pickClosest` → `registry.record`), so `remapOne` uses the same `SelectionTools.graphIndex(...)` resolution as `select_topology`: both selectionId-minting paths have to agree on the graph-index convention (#91)
- `SelectionBridgeTools.swift`: `get_selection`, `highlight_selection` (#189/#190): the agent-to-viewport-host selection bridge, per the wire format in `SecondMouseAU/OCCTSwiftInteraction#17` (in review in parallel with this work; schema treated as final regardless of later wording changes there). OCCTMCP is a stdio MCP process with no persistent link to any app's live `InteractiveContext`/`ViewportService`, so neither tool calls into a live selection directly: both speak sidecar files in the resolved output directory (`Paths.swift`), the same convention every other OCCTMCP sidecar already uses. `HostLock.checkLiveness(outputDir:)` is the shared liveness primitive both tools consult: a host holds an exclusive `flock` on `host.lock` for its whole lifetime, and a non-blocking SHARED lock probe distinguishes `.noHost` (probe succeeds, nothing holds the exclusive lock) from `.hostRunning` (probe fails, something does) without ever blocking the calling thread. `get_selection` reads `selection.json` (`{selections:[{bodyId,kind,index,uid?}], revision, updatedAt}`) and returns a three-state result (`state: "noHost"` with `selections: null`, vs `state: "hostRunning"` with `selections: []` or `[...]`) so an agent can never confuse "no viewport" with "viewport, nothing selected". Each entry resolves against THIS server's own scene the same way `select_topology` resolves a match: enumerate the body's faces/edges/vertices in the same order `select_topology` does, index into that enumeration at the wire entry's `index`, then convert to a `BRepGraph` node index via the existing `SelectionTools.graphIndex(...)` convention before minting the `SelectionRegistry` entry, so the resulting `selectionId` composes with `remap_selection`/`measure_distance`/etc. exactly like one `select_topology` minted itself. A per-entry resolution failure (unknown `bodyId`, out-of-range `index`) is reported inline on that entry (`error` set, `selectionId`/`anchor` nil) rather than failing the whole response; a RUNNING host with `selection.json` missing or malformed (a torn read, per the ADR's atomicity rule not yet being honored by some future writer) is reported as an explicit tool error instead, since the host is expected to maintain that sidecar for its whole lifetime and a missing/torn file while live is anomalous, not "nothing selected". `highlight_selection` writes `highlight_requests/<id>.json` (`{id,bodyId,kind,index,scheme,question?}`, `scheme` matching `OCCTSwiftAIS.SelectionScheme` exactly: `replace`/`add`/`remove`/`xor`) atomically (`Data.write(to:options:.atomic)`, i.e. temp name + rename) and polls `highlight_requests/handled/<id>.json` for a bounded timeout (default 5s), returning the host's real `applied`/`rejected`/`superseded` outcome, an explicit `timeout` if nothing answers, or `noHost` immediately (no request written) if no host is running at all, rather than hanging out the full timeout for a request nobody will ever read. `kind`/`scheme` are validated against their closed wire-format enums before writing (a malformed request is never written), but `bodyId`/`index` are written through UNVALIDATED against the live scene: this tool has no other access to check them, so a bad reference still round-trips as the host's own `rejected` outcome through the same poll, never a client-side pre-check. `id` is generated by the tool itself (never caller-supplied), so a caller never needs to invent or coordinate its own id across concurrent highlight calls
- `AnnotationsTools.swift`: `add_dimension`, `add_scene_primitive`, `remove_scene_annotation`
- `GapFillerTools.swift`: `show_bounding_box`, `diff_overlay`, `select_by_feature`
- `IntrospectionRegistryTools.swift`: `list_selections`, `clear_selections`, `list_annotations`, `list_zones`, `clear_zones` (#101, `RegistryIntrospectionTools`; the latter two mirror `list_selections`/`clear_selections` against `ZoneRegistry`)
Expand Down Expand Up @@ -358,7 +359,7 @@ Verify what a fresh clone / CI actually resolves (not the local sibling-checkout

## MCP Tools

77 tools in Swift; 37 in Node (no selection / remap / annotations / history / reconstruct / mesh-zone analysis / mesh inspection / alignment / curvature / mesh features). See README.md for the categorized table: that's the LLM-facing surface and stays canonical.
79 tools in Swift; 37 in Node (no selection / remap / annotations / history / reconstruct / mesh-zone analysis / mesh inspection / alignment / curvature / mesh features / viewport selection bridge). See README.md for the categorized table: that's the LLM-facing surface and stays canonical.

## Script Template

Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ MCP server that gives LLMs the ability to author, inspect, and iterate on 3D CAD

Part of the [OCCTSwift ecosystem](https://github.com/SecondMouseAU/OCCTSwift/blob/main/docs/ecosystem.md) — see the ecosystem map for how this package sits on top of the kernel, viewport, bridge, and AIS layers. SemVer-stable from v1.0.0.

The Swift implementation calls OCCT directly in-process (no subprocess, no JSONL marshalling) and exposes 77 typed MCP tools that cover authoring, scene reads, mutation, introspection, construction, analysis, I/O, mesh, drawing, selection / remap, mesh-zone analysis, mesh inspection, alignment, and dimension overlays.
The Swift implementation calls OCCT directly in-process (no subprocess, no JSONL marshalling) and exposes 79 typed MCP tools that cover authoring, scene reads, mutation, introspection, construction, analysis, I/O, mesh, drawing, selection / remap, mesh-zone analysis, mesh inspection, alignment, dimension overlays, and the agent-to-viewport-host selection bridge.

## How It Works

Expand All @@ -23,7 +23,7 @@ For novel geometry the typed tools don't cover, the LLM falls back to `execute_s

## Tools

77 tools, organized below. Call `get_api_reference({ category: "mcp_tools" })` to dump every tool's JSON Schema in one shot, useful for LLM auto-discovery. Most flows can answer "what's the volume?", "make it red", "boolean-subtract these", "render a preview", "add a dimension between these two faces", "export to STEP", and "draw this" without ever touching `execute_script`.
79 tools, organized below. Call `get_api_reference({ category: "mcp_tools" })` to dump every tool's JSON Schema in one shot, useful for LLM auto-discovery. Most flows can answer "what's the volume?", "make it red", "boolean-subtract these", "render a preview", "add a dimension between these two faces", "export to STEP", and "draw this" without ever touching `execute_script`.

### Authoring

Expand Down Expand Up @@ -139,6 +139,8 @@ The mesh-domain check-list / measurement surface (Phase 2 of the mesh-analysis e
| `select_by_feature` | Bulk pick by feature kind (e.g. all hole edges) |
| `list_selections` | Inspect the in-memory selection registry |
| `clear_selections` | Wipe the registry |
| `get_selection` (#189) | Read a live viewport host's current selection (`<output_dir>/selection.json` + `host.lock`, per `SecondMouseAU/OCCTSwiftInteraction#17`). Three-state result: `noHost` vs `hostRunning` with an empty or populated selection list; each entry resolves against this server's own scene the same way `select_topology` does and mints a composable `selectionId` |
| `highlight_selection` (#190) | Ask a live viewport host to highlight one sub-shape (`replace`/`add`/`remove`/`xor`, mirroring `OCCTSwiftAIS.SelectionScheme`). Writes `highlight_requests/<id>.json`, polls `highlight_requests/handled/<id>.json` for the host's real `applied`/`rejected`/`superseded` outcome, or an explicit `timeout`/`noHost` |

### Annotations & overlays

Expand Down Expand Up @@ -199,7 +201,7 @@ LLM read/write over an attributed reconstruction graph — annotate per-node dec

This repo ships two implementations side-by-side:

- **Swift** (`Sources/`, `Package.swift`): the **primary** server. In-process against OCCTSwift / OCCTSwiftMesh / OCCTSwiftTools / OCCTSwiftAIS / DrawingComposer using the [official Swift MCP SDK](https://swiftpackageindex.com/modelcontextprotocol/swift-sdk). 77 tools. macOS 15+ (the OCCT.xcframework arm64 platform).
- **Swift** (`Sources/`, `Package.swift`): the **primary** server. In-process against OCCTSwift / OCCTSwiftMesh / OCCTSwiftTools / OCCTSwiftAIS / DrawingComposer using the [official Swift MCP SDK](https://swiftpackageindex.com/modelcontextprotocol/swift-sdk). 79 tools. macOS 15+ (the OCCT.xcframework arm64 platform).
- **Node / TypeScript** (`src/`, `dist/`) — the original implementation. Shells out to the `occtkit` CLI for everything Swift-side. 37 tools (the pre-v0.4 surface; selection / remap / annotations are Swift-only). Useful if you can't run a macOS binary.

Both speak stdio MCP and read/write the same manifest format.
Expand Down
72 changes: 72 additions & 0 deletions Sources/OCCTMCPCore/Server.swift
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,56 @@ func catalogTools() -> [Tool] {
"additionalProperties": .bool(false),
])
),
Tool(
name: "get_selection",
description:
"Read the live viewport host's current selection (SecondMouseAU/OCCTSwiftInteraction#17: <output_dir>/selection.json + host.lock). Three-state result: state=\"noHost\" (no viewport host is running at all, selections is null) vs state=\"hostRunning\" with selections=[] (host live, nothing picked) vs selections=[...] (host live, N items picked): never collapse those into one boolean/empty-array reading. Each selection is resolved against this server's own scene the same way select_topology resolves a match (area/bounds/centroid for a face, length/curveType/endpoints for an edge, position for a vertex) and mints a selectionId that composes with remap_selection/measure_distance/add_dimension/etc. A host running but missing or malformed selection.json is reported as an explicit error, never swallowed into an empty selection list.",
inputSchema: .object([
"type": .string("object"),
"properties": .object([:]),
"additionalProperties": .bool(false),
])
),
Tool(
name: "highlight_selection",
description:
"Ask the live viewport host to highlight one sub-shape (SecondMouseAU/OCCTSwiftInteraction#17: writes <output_dir>/highlight_requests/<id>.json, polls highlight_requests/handled/<id>.json for the real outcome). scheme mirrors OCCTSwiftAIS.SelectionScheme exactly: \"replace\" swaps the host's whole selection, \"add\"/\"remove\" adjust it, \"xor\" toggles. bodyId/kind/index are written through unvalidated against the live scene (this tool has no other access to check them); an unknown bodyId or out-of-range index still comes back as the host's own rejected outcome through the same poll, not a client-side pre-check. Returns outcome=\"noHost\" immediately (no request written) if no viewport host is running, \"timeout\" if the host never writes a handled/ response within the deadline, or the host's own applied/rejected/superseded outcome.",
inputSchema: .object([
"type": .string("object"),
"properties": .object([
"bodyId": .object(["type": .string("string")]),
"kind": .object([
"type": .string("string"),
"enum": .array([
.string("body"), .string("face"), .string("edge"), .string("vertex"),
]),
]),
"index": .object(["type": .string("integer")]),
"scheme": .object([
"type": .string("string"),
"enum": .array([
.string("replace"), .string("add"), .string("remove"), .string("xor"),
]),
]),
"question": .object([
"type": .string("string"),
"description": .string(
"Optional natural-language context for the host to show alongside the highlight, e.g. when confirming an ambiguous pick with a human."
),
]),
"timeoutSeconds": .object([
"type": .string("number"),
"description": .string(
"How long to poll handled/<id>.json before returning outcome=\"timeout\". Default 5.0."
),
]),
]),
"required": .array([
.string("bodyId"), .string("kind"), .string("index"), .string("scheme"),
]),
"additionalProperties": .bool(false),
])
),
Tool(
name: "ping",
description:
Expand Down Expand Up @@ -2366,6 +2416,28 @@ func dispatch(callName: String, arguments: [String: Value]) async -> CallTool.Re
bodyId: bodyId, kind: kind, filter: filter, limit: limit
).asCallToolResult()

case "get_selection":
return await SelectionBridgeTools.getSelection().asCallToolResult()

case "highlight_selection":
guard let bodyId = arguments["bodyId"]?.stringValue,
let kind = arguments["kind"]?.stringValue,
let index = arguments["index"]?.intValue,
let scheme = arguments["scheme"]?.stringValue
else {
return ToolText(
"highlight_selection requires `bodyId`, `kind`, `index`, and `scheme`.",
isError: true
).asCallToolResult()
}
let timeout =
arguments["timeoutSeconds"]?.numberValue ?? SelectionBridgeTools.defaultTimeoutSeconds
return await SelectionBridgeTools.highlightSelection(
bodyId: bodyId, kind: kind, index: index, scheme: scheme,
question: arguments["question"]?.stringValue,
timeoutSeconds: timeout
).asCallToolResult()

case "set_assembly_metadata":
guard let inputPath = arguments["inputPath"]?.stringValue,
let outputPath = arguments["outputPath"]?.stringValue
Expand Down
Loading
Loading