#autodeploy Rewrite FreeCAD handlers to use persistent bridge architecture#132
#autodeploy Rewrite FreeCAD handlers to use persistent bridge architecture#132aleksclark wants to merge 10 commits into
Conversation
Comprehensive integration for FreeCAD CAD software with 33 tools covering: - Document management (list, create, open, get) - Object management (list, get, delete, placement, properties) - 3D primitives (box, cylinder, sphere, cone, torus) - Boolean operations (cut, fuse, common) - Shape operations (fillet, chamfer, extrude, mirror) - Measurement and geometry analysis - Export (STEP, STL, BRep) and import - Custom Python scripting Executes via local freecad CLI with Python scripting. Auto-detects binary from PATH, configurable via FREECAD_BINARY_PATH and FREECAD_DATA_DIR environment variables. 🐘 Generated with Crush Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
acmacalister
left a comment
There was a problem hiding this comment.
Good integration overall — clean architecture, solid dispatch parity, and the extractJSON noise-stripping is a clever approach to FreeCAD's chatty console output. Found a few things worth looking at, mostly around the jsonScriptResult dead branch and a zero-value edge case in optFloat.
| if json.Valid([]byte(out)) { | ||
| return &mcp.ToolResult{Data: out}, nil | ||
| } | ||
| return &mcp.ToolResult{Data: out}, nil |
There was a problem hiding this comment.
The two branches of the json.Valid check return the same thing — invalid JSON silently gets returned as a {Data: out, IsError: false} result instead of being flagged. The else-branch should either return an error or at minimum set IsError: true so callers know the script didn't produce valid JSON:
if json.Valid([]byte(out)) {
return &mcp.ToolResult{Data: out}, nil
}
return &mcp.ToolResult{Data: out, IsError: true}, nilRight now, if FreeCAD produces unexpected non-JSON output (Python traceback, banner noise that extractJSON couldn't strip), the tool returns it as though it succeeded.
| // optFloat extracts a float64 from args with a default value. | ||
| func optFloat(args map[string]any, key string, def float64) float64 { | ||
| v, err := mcp.ArgFloat64(args, key) | ||
| if err != nil || v == 0 { |
There was a problem hiding this comment.
optFloat treats v == 0 the same as "not provided", so it's impossible to pass a legitimate zero value — e.g. createBox with length=0 or createCone with radius2=0 (a pointed cone, which is a valid and common shape). The TestOptFloat/zero uses default test is actually encoding this bug as correct behavior.
The right fix is to only fall back to the default when the key is absent:
func optFloat(args map[string]any, key string, def float64) float64 {
v, ok := args[key]
if !ok || v == nil {
return def
}
f, err := mcp.ArgFloat64(args, key)
if err != nil {
return def
}
return f
}Note that radius2 for cones is documented as "default 0 for pointed cone", so this particularly affects that tool.
| // --- Python execution helpers --- | ||
|
|
||
| func (f *freecad) runScript(ctx context.Context, script string) (string, error) { | ||
| f.mu.Lock() |
There was a problem hiding this comment.
The mutex in runScript serializes every FreeCAD call globally — only one script can run at a time per integration instance. That's probably fine for a single-user CLI setup (FreeCAD's own Python runtime is single-threaded per process), but worth a comment here to explain the design intent. If concurrent usage is ever expected, the right approach would be a worker pool rather than a simple mutex, since FreeCAD can be invoked as separate processes in parallel.
| func (f *freecad) filePath(name string) string { | ||
| if filepath.IsAbs(name) { | ||
| return name | ||
| } |
There was a problem hiding this comment.
filePath joins dataDir with a user-supplied relative path via filepath.Join, but doesn't prevent traversal outside the data directory. A path like ../../etc/passwd would resolve cleanly to a path outside dataDir. Since this ends up inside a Python script passed to FreeCAD, it could read or write arbitrary files.
func (f *freecad) filePath(name string) string {
if filepath.IsAbs(name) {
return name
}
resolved := filepath.Join(f.dataDir, name)
// Prevent traversal outside dataDir for relative paths
if !strings.HasPrefix(resolved, f.dataDir+string(filepath.Separator)) && resolved != f.dataDir {
return filepath.Join(f.dataDir, filepath.Base(name))
}
return resolved
}If intentional cross-directory access is needed, consider restricting it to absolute paths only (which are already passed through as-is) and rejecting relative paths that contain ...
| @@ -32,7 +32,7 @@ func TestLoad_CreatesDefaultWhenMissing(t *testing.T) { | |||
| _, err = os.Stat(path) | |||
| assert.NoError(t, err) | |||
|
|
|||
There was a problem hiding this comment.
"freecad" is missing from the integration name list here — the test asserts 30 integrations exist but only checks 28 names. Adding it would catch any accidental future breakage of the freecad default config entry:
for _, name := range []string{"github", ..., "fly", "snowflake", "freecad"} {The expected credentials map in TestDefaultConfig (around line 258) also doesn't include the freecad entry with its binary_path and data_dir keys.
Replace one-shot jsonScriptResult/scriptResult pattern with persistent f.execPython bridge calls across all handler files. Key changes: - Use _result_ variable instead of print(json.dumps(...)) for return data - Documents persist in the FreeCAD process — open once, reuse via lookup - Remove import FreeCAD/json from Python scripts (available in exec globals) - Remove duplicate optFloat from primitives.go (now in freecad.go) - Add saveDocument and closeDocument handlers + tool definitions - Remove orphan Sketcher/PartDesign dispatch entries (no implementations yet) - Remove extractJSON test and dir-based listDocuments test (obsolete patterns) 🐘 Generated with Crush Assisted-by: via Crush <crush@charm.land>
- Fix gofmt alignment in freecad.go struct fields - Silence errcheck on fmt.Sscanf in XMLRPC value parser - Update Configure tests to tolerate missing bridge in CI 🐘 Generated with Crush Assisted-by: via Crush <crush@charm.land>
acmacalister
left a comment
There was a problem hiding this comment.
Architecture and bridge lifecycle look solid — the switch to a persistent HTTP+XMLRPC bridge is a big improvement over one-shot process spawning. Three things to look at inline.
|
|
||
| // Launch: echo 'exec(open("bridge.py").read())' | freecad --console | ||
| launcher := fmt.Sprintf(`exec(open(%q).read())`, f.bridgePath) | ||
| cmd := exec.CommandContext(ctx, f.binary, "--console") // #nosec G204 -- binary path from trusted config |
There was a problem hiding this comment.
Using exec.CommandContext(ctx, ...) here ties the bridge process lifetime to the calling request's context. When that context is cancelled or times out, Go will automatically kill the FreeCAD process — but the bridge is supposed to be persistent across many requests. The second call to any tool after the first one's context expires will trigger a full restart.
The bridge startup should use context.Background() and reserve ctx only for the TCP ready-wait loop:
cmd := exec.Command(f.binary, "--console") // #nosec G204| // optFloat extracts a float64 from args with a default value. | ||
| func optFloat(args map[string]any, key string, def float64) float64 { | ||
| v, err := mcp.ArgFloat64(args, key) | ||
| if err != nil || v == 0 { |
There was a problem hiding this comment.
v == 0 conflates "not provided" with "explicitly zero". For most numeric dims like length and radius this is fine (zero-size shapes are nonsensical anyway), but it breaks for extrude direction components and for createCone's radius2 — a user can't intentionally pass dx=0 or radius2=0 when the default is already 0, and for torus radius2 they can't override the 2 default back to zero. If the field is truly optional, mcp.ArgFloat64 returning an error is the signal for "not provided" — the || v == 0 guard isn't needed and should be removed.
func optFloat(args map[string]any, key string, def float64) float64 {
v, err := mcp.ArgFloat64(args, key)
if err != nil {
return def
}
return v
}|
|
||
| assert.Len(t, m.cfg.Integrations, 29) | ||
| assert.Len(t, m.cfg.Integrations, 30) | ||
| for _, name := range []string{"github", "datadog", "linear", "sentry", "slack", "metabase", "aws", "posthog", "postgres", "clickhouse", "elasticsearch", "pganalyze", "rwx", "gmail", "homeassistant", "notion", "ynab", "gcp", "suno", "amazon", "jira", "confluence", "readarr", "salesforce", "cloudflare", "digitalocean", "fly", "snowflake"} { |
There was a problem hiding this comment.
The count was updated to 30, but freecad (and acp/snowflake) weren't added to the name list in this loop — so the test still only checks 28 of the 30 integrations. The missing ones won't be caught if someone accidentally removes their default config entry.
Add "freecad" (and any other new ones) to both this loop and the expected map in TestDefaultConfig below, so the assertion actually exercises the new entries.
- Add 20 new tools: 9 sketcher (create_sketch, add_sketch_line/circle/arc/ rectangle/polygon, add_constraint, get_sketch, delete_sketch_geometry) and 11 PartDesign (create_body, pad, pocket, revolution, groove, pd_fillet, pd_chamfer, pd_mirror, linear_pattern, polar_pattern, pd_hole) - Rewrite adapter to connect to a running FreeCAD instance with the RobustMCPBridge addon (XMLRPC on localhost:9875) instead of launching a headless process — documents persist and are visible in the GUI - Fix XMLRPC parser to handle <array> and <nil> types - Add pyBool helper for Go→Python boolean conversion - All handlers use _result_ convention for the RobustMCPBridge execute() protocol - Total: 53 tools, all validated against live FreeCAD 🐘 Generated with Crush Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
# Conflicts: # config/config_test.go
f562c5b to
bbedebb
Compare
acmacalister
left a comment
There was a problem hiding this comment.
Solid work overall — the bridge architecture is clean, dispatch/compaction parity tests are all there, and the test coverage across handlers is thorough. Two things worth a look inline.
| } | ||
| defer func() { _ = resp.Body.Close() }() | ||
|
|
||
| body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) |
There was a problem hiding this comment.
The HTTP response status code isn't checked before passing body to parseXMLRPCResponse. If the bridge returns a non-200 (e.g., 404 when the addon isn't loaded, or a 500 from a panic), the XML parser will fail on whatever error HTML the server returned, producing a confusing freecad: parse xmlrpc error with no hint that the HTTP layer is the problem.
A quick status check makes the failure mode much clearer:
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("freecad: bridge returned HTTP %d — is FreeCAD running with the RobustMCPBridge addon?", resp.StatusCode)
}| assert.Equal(t, "freecad", i.Name()) | ||
| } | ||
|
|
||
| func TestConfigure_AutoDetect(t *testing.T) { |
There was a problem hiding this comment.
TestConfigure_AutoDetect carries a leftover comment from when this adapter launched a freecad binary. In the current bridge architecture, Configure always succeeds when data_dir resolves (it defaults to ~/FreeCAD), so this test never exercises an error path — it's a no-op assertion.
Either rename it to TestConfigure_DefaultsToLocalhost and assert on fc.host and fc.port values, or add a case that forces the MkdirAll path to fail (e.g., by setting data_dir to a path under a read-only directory).
Add freecad_get_document_errors (lists invalid objects, null shapes, error states) and freecad_get_solver_status (sketch DOF, constraint solver diagnostics). Total: 55 tools. 🐘 Generated with Crush Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
acmacalister
left a comment
There was a problem hiding this comment.
Good work on the persistent bridge architecture — the XMLRPC approach is cleaner than one-shot process spawning, and the dispatch/compaction parity tests are solid. Three small things inline.
| assert.Len(t, m.cfg.Integrations, 31) | ||
| for _, name := range []string{"github", "datadog", "linear", "sentry", "slack", "metabase", "aws", "posthog", "postgres", "clickhouse", "elasticsearch", "pganalyze", "rwx", "gmail", "homeassistant", "notion", "ynab", "gcp", "suno", "amazon", "jira", "confluence", "readarr", "salesforce", "cloudflare", "digitalocean", "fly", "snowflake", "web", "botidentity"} { | ||
| assert.Len(t, m.cfg.Integrations, 32) | ||
| for _, name := range []string{"github", "datadog", "linear", "sentry", "slack", "metabase", "aws", "posthog", "postgres", "clickhouse", "elasticsearch", "pganalyze", "rwx", "gmail", "homeassistant", "notion", "ynab", "gcp", "suno", "amazon", "jira", "confluence", "readarr", "salesforce", "cloudflare", "digitalocean", "fly", "snowflake", "web", "botidentity", "freecad"} { |
There was a problem hiding this comment.
The count was bumped to 32 but "acp" is still absent from this name list — there are only 31 entries, so acp goes unchecked. If its default config entry ever disappears, no test would catch it.
Just add "acp" to the slice:
for _, name := range []string{"github", ..., "fly", "snowflake", "web", "botidentity", "acp", "freecad"} {| if raw == "" { | ||
| return &mcp.ToolResult{Data: `{"status":"ok"}`}, nil | ||
| } | ||
| return &mcp.ToolResult{Data: raw}, nil |
There was a problem hiding this comment.
When json.Unmarshal fails and raw is non-empty (e.g. a Python traceback from an uncaught exception, or raw stdout the bridge injected before the JSON), this returns {Data: raw, IsError: false}. The LLM sees a success result containing an error string, which is confusing.
Setting IsError: true on this path would make the failure visible:
return &mcp.ToolResult{Data: raw, IsError: true}, nil| } | ||
| docName, _ := mcp.ArgStr(args, "doc_name") | ||
| bodyName, _ := mcp.ArgStr(args, "body_name") | ||
| _, _ = mcp.ArgStr(args, "direction") // reserved for future axis selection |
There was a problem hiding this comment.
The direction parameter is read and immediately discarded — the Python script that follows always uses the body's default axis regardless of what was passed. The tools.go description says 'H_Axis' or 'V_Axis' (default 'H_Axis'), so an LLM will pass it expecting it to work, then get a silently different result.
Either wire it into the script or drop it from the tool definition until it's actually implemented.
- Fix optFloat to allow explicit zero values (pointed cones, zero offsets) - Add path traversal protection to filePath for relative paths - Add HTTP status code check before XMLRPC response parsing - Add freecad to config_test.go expected credentials map - Fix TestConfigure_AutoDetect to assert on default host/port values - Add TestFilePath/traversal_blocked test case - Fix TestOptFloat/zero to expect zero passthrough, not default Stale comments (json.Valid dead branch, mutex serialization, exec.CommandContext lifetime) were already resolved in the bridge rewrite commit. 🐘 Generated with Crush Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
acmacalister
left a comment
There was a problem hiding this comment.
Build passes, full test suite green, lint clean. Two existing open issues still need attention (the IsError false-negative in execPython and the direction no-op in linearPattern), plus one new one I noticed on freecad_list_documents — details inline.
|
|
||
| // ── Document Management ───────────────────────────────────────── | ||
| { | ||
| Name: mcp.ToolName("freecad_list_documents"), Description: "List all FreeCAD CAD document files (.FCStd) in the data directory. Start here to discover existing 3D models, designs, and parts.", |
There was a problem hiding this comment.
The description says "List all FreeCAD CAD document files (.FCStd) in the data directory" but the implementation calls FreeCAD.listDocuments() which returns documents that are currently open in memory — not files on disk. An LLM following this description will call this tool expecting to discover existing files and get back an empty list if no documents have been opened yet.
Either scan dataDir for .FCStd files here, or update the description to say something like "List all FreeCAD documents currently open in the running FreeCAD process."
| if raw == "" { | ||
| return &mcp.ToolResult{Data: `{"status":"ok"}`}, nil | ||
| } | ||
| return &mcp.ToolResult{Data: raw}, nil |
There was a problem hiding this comment.
When json.Unmarshal fails and raw is non-empty — e.g. a Python traceback or unexpected stdout the bridge injected — this returns {Data: raw, IsError: false}. The tool appears to have succeeded, but the data field contains an error message. Should be IsError: true when we can't parse the response as the expected bridge envelope:
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
if raw == "" {
return &mcp.ToolResult{Data: `{"status":"ok"}`}, nil
}
return &mcp.ToolResult{Data: raw, IsError: true}, nil
}| } | ||
| docName, _ := mcp.ArgStr(args, "doc_name") | ||
| bodyName, _ := mcp.ArgStr(args, "body_name") | ||
| _, _ = mcp.ArgStr(args, "direction") // reserved for future axis selection |
There was a problem hiding this comment.
The direction parameter is still silently ignored — _, _ = mcp.ArgStr(args, "direction") reads and discards it, and the Python script always uses the body's default axis. tools.go advertises 'H_Axis' or 'V_Axis' (default 'H_Axis'), so callers will pass it and get a result that doesn't match what they asked for.
Worth either wiring it into the script (the PartDesign::LinearPattern object has a Direction property you can set via its Origin axis feature) or dropping the parameter from the tool definition until it's implemented.
# Conflicts: # config/config_test.go
Add freecad_screenshot tool for capturing 3D viewport as base64 PNG. Finds the 3D view even when a non-3D view (TechDraw, Spreadsheet) is active. Supports view angle presets (Isometric, Front, Top, etc). Total: 56 tools. 🐘 Generated with Crush Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
Summary
f.execPython(ctx, script)bridge pattern instead of one-shotjsonScriptResult/scriptResult_result_ = {...}to return data instead ofprint(json.dumps(...)); documents stay open in the persistent FreeCAD process and are looked up before openingsaveDocumentandcloseDocumenthandlers with tool definitions; remove orphan Sketcher/PartDesign dispatch entries and obsolete test helpersTest plan
go test ./...)go build ./...clean🐘 Generated with Crush