app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
//! `fusion_mcp_*`: Autodesk's own Fusion MCP server on 127.0.0.1:27182 (Streamable HTTP).
//! The bridge is a plain JSON-RPC client of it; `mcp_enable` flips the Preferences toggle
//! Autodesk exposes no API for.
use crate::addin::Ctx;
use serde_json::{json, Value};
use std::sync::Mutex;
use std::time::Duration;
pub const MCP_URL: &str = "http://127.0.0.1:27182/mcp";
static SESSION_ID: Mutex<Option<String>> = Mutex::new(None);
static RPC_LOCK: Mutex<()> = Mutex::new(());
const OFF_STATIC: &str = "Fusion's MCP server is not reachable on 127.0.0.1:27182. It is OFF by default. The bridge can turn it on FOR the user: call fusion_mcp_enable, which opens Preferences, expands General, selects API, ticks 'Fusion MCP Server (runs locally on this device)' and clicks Apply/OK. It needs Fusion running and briefly uses the foreground (announced with an on-screen caption). Manual path if you prefer: profile icon (top right) -> Preferences -> General -> API -> tick the box. NOTE the setting does NOT survive if Fusion is killed before Apply is clicked.";
/// `{status, headers, body}` / `{httpError, body}` / `{error}` for one POST.
fn post(body: &Value, sid: Option<&str>, timeout: u64) -> Value {
let mut req = ureq::post(MCP_URL).timeout(Duration::from_secs(timeout)).set("Content-Type", "application/json").set("Accept", "application/json, text/event-stream");
if let Some(s) = sid { req = req.set("MCP-Session-Id", s); }
match req.send_string(&body.to_string()) {
Ok(r) => { let sid = r.header("mcp-session-id").map(str::to_string); let status = r.status(); let text = r.into_string().unwrap_or_default(); json!({"status": status, "sessionId": sid, "body": text}) }
Err(ureq::Error::Status(code, r)) => { let text = r.into_string().unwrap_or_default(); json!({"httpError": code, "body": text.chars().take(1000).collect::<String>()}) }
Err(e) => json!({"error": format!("{}", e).chars().take(220).collect::<String>()}),
}
}
/// initialize + notifications/initialized: `{sessionId, serverInfo}` or `{error}`.
pub fn new_session(version: &str) -> Value {
let r = post(&json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "fusion-bridge", "version": version}}}), None, 30);
if !r["error"].is_null() || !r["httpError"].is_null() {
return json!({"error": if r["error"].is_null() { format!("HTTP {}", r["httpError"]) } else { r["error"].as_str().unwrap_or("").to_string() }, "raw": r["body"]});
}
let sid = r["sessionId"].as_str().map(str::to_string);
let info = serde_json::from_str::<Value>(r["body"].as_str().unwrap_or("{}")).map(|v| v["result"]["serverInfo"].clone()).unwrap_or(json!({}));
if let Some(s) = &sid { let _ = post(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), Some(s), 30); *SESSION_ID.lock().unwrap() = Some(s.clone()); }
json!({"sessionId": sid, "serverInfo": if info.is_null() { json!({}) } else { info }})
}
/// One JSON-RPC call with automatic session (re)establishment; `{_mcpError}` on failure.
pub fn rpc(version: &str, method: &str, params: Option<Value>, timeout: u64) -> Value {
let sid = {
let _g = RPC_LOCK.lock().unwrap();
if SESSION_ID.lock().unwrap().is_none() { let s = new_session(version); if !s["error"].is_null() { return json!({"_mcpError": s["error"]}); } }
SESSION_ID.lock().unwrap().clone()
};
let mut body = json!({"jsonrpc": "2.0", "id": 7, "method": method});
if let Some(p) = params { body["params"] = p; }
let mut r = post(&body, sid.as_deref(), timeout);
if matches!(r["httpError"].as_u64(), Some(400) | Some(404)) && r["body"].as_str().unwrap_or("").contains("ession") {
let sid2 = { let _g = RPC_LOCK.lock().unwrap(); *SESSION_ID.lock().unwrap() = None; let s = new_session(version); if !s["error"].is_null() { return json!({"_mcpError": s["error"]}); } SESSION_ID.lock().unwrap().clone() };
r = post(&body, sid2.as_deref(), timeout);
}
if !r["error"].is_null() || !r["httpError"].is_null() {
return json!({"_mcpError": if r["error"].is_null() { format!("HTTP {}: {}", r["httpError"], r["body"].as_str().unwrap_or("").chars().take(200).collect::<String>()) } else { r["error"].as_str().unwrap_or("").to_string() }});
}
serde_json::from_str::<Value>(r["body"].as_str().unwrap_or("{}")).unwrap_or_else(|_| json!({"_mcpError": "unparseable response", "raw": r["body"].as_str().unwrap_or("").chars().take(400).collect::<String>()}))
}
/// The MCP-unreachable hint, state-aware: never send the AI to enable MCP when APS is signed
/// in and could serve a file search right now. `aps` = (configured, signedIn).
pub fn off_hint(aps: (bool, bool)) -> String {
let lead = if aps.1 { "NOTE FIRST: APS is signed in, so for FILE SEARCH you do not need MCP at all - fusion_aps_search works right now. Only enable MCP if you need its other surface (script text-to-CAD, screenshots, electronics object-model reads). " }
else if aps.0 { "NOTE: for FILE SEARCH, APS is configured and only needs fusion_aps_signin (silent on a warm SSO session) - that may be less disruptive than enabling MCP. " } else { "" };
format!("{lead}{OFF_STATIC}")
}
pub fn status(ctx: &Ctx, version: &str, aps: (bool, bool)) -> Value {
let s = new_session(version);
if !s["error"].is_null() {
return json!({"success": true, "enabled": false, "url": MCP_URL, "error": s["error"], "lastEnabledAt": crate::small_verbs::get_pref(&ctx.state_dir, "mcpLastEnabledAt").unwrap_or(Value::Null),
"fusionRunning": !ctx.host.fusion_processes().is_empty(), "_hint": off_hint(aps), "statusVerb": "fusion_mcp_status"});
}
let tl = rpc(version, "tools/list", None, 60);
let tools: Vec<Value> = if tl["_mcpError"].is_null() { tl["result"]["tools"].as_array().cloned().unwrap_or_default().iter().map(|t| json!({"name": t["name"], "description": t["description"].as_str().unwrap_or("").split('\n').next().unwrap_or("").chars().take(160).collect::<String>()})).collect() } else { vec![] };
crate::small_verbs::set_pref(&ctx.state_dir, "mcpLastEnabledAt", json!(chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string()));
json!({"success": true, "enabled": true, "url": MCP_URL, "sessionId": s["sessionId"], "serverInfo": s["serverInfo"], "toolCount": tools.len(), "tools": tools,
"_hint": "Autodesk's Fusion MCP server is LIVE. Its tools are Autodesk's own, not this bridge's: call them with fusion_mcp_call {tool, arguments}. Use fusion_mcp_tools for full input schemas and fusion_mcp_resources for the Electronics entity schemas. These COMPLEMENT the fusion_* verbs: prefer a native verb when one exists (it is tested and returns richer hints), and reach for MCP for Autodesk's text-to-CAD surface.", "statusVerb": "fusion_mcp_status"})
}
pub fn tools(version: &str, aps: (bool, bool)) -> Value {
let tl = rpc(version, "tools/list", None, 60);
if !tl["_mcpError"].is_null() { return json!({"success": false, "enabled": false, "error": tl["_mcpError"], "_hint": off_hint(aps)}); }
let tools = tl["result"]["tools"].as_array().cloned().unwrap_or_default();
json!({"success": true, "count": tools.len(), "tools": tools, "_hint": "Call one with fusion_mcp_call {\"tool\":\"<name>\",\"arguments\":{...}}.", "statusVerb": "fusion_mcp_status"})
}
pub fn call(version: &str, args: &Value, aps: (bool, bool)) -> Value {
let Some(tool) = args["tool"].as_str().filter(|s| !s.is_empty()) else { return json!({"success": false, "error": "Pass {tool, arguments}.", "_hint": "fusion_mcp_tools lists the available tools and their input schemas."}) };
let arguments = if args["arguments"].is_object() { args["arguments"].clone() } else { json!({}) };
let r = rpc(version, "tools/call", Some(json!({"name": tool, "arguments": arguments})), args["timeout"].as_u64().filter(|t| *t > 0).unwrap_or(120));
if !r["_mcpError"].is_null() { return json!({"success": false, "error": r["_mcpError"], "_hint": off_hint(aps)}); }
let res = if r["result"].is_object() { r["result"].clone() } else { json!({}) };
json!({"success": res["isError"].as_bool() != Some(true), "tool": tool, "result": res,
"_hint": "Autodesk MCP tool result. Content is usually a list of {type,text} blocks. If it complains about no active document, open one first (fusion_aps_open) - MCP acts on the ACTIVE Fusion document.", "statusVerb": "fusion_mcp_status"})
}
pub fn resources(version: &str, args: &Value, aps: (bool, bool)) -> Value {
if let Some(uri) = args["uri"].as_str().filter(|s| !s.is_empty()) {
let r = rpc(version, "resources/read", Some(json!({"uri": uri})), 60);
if !r["_mcpError"].is_null() { return json!({"success": false, "error": r["_mcpError"], "_hint": off_hint(aps)}); }
return json!({"success": true, "uri": uri, "result": r["result"], "statusVerb": "fusion_mcp_status"});
}
let r = rpc(version, "resources/list", None, 60);
if !r["_mcpError"].is_null() { return json!({"success": false, "error": r["_mcpError"], "_hint": off_hint(aps)}); }
let res = r["result"]["resources"].as_array().cloned().unwrap_or_default();
json!({"success": true, "count": res.len(), "resources": res.iter().map(|x| x["uri"].clone()).collect::<Vec<_>>(), "_hint": "Read one with fusion_mcp_resources {\"uri\":\"resource://...\"}.", "statusVerb": "fusion_mcp_status"})
}
/// Turn Autodesk's MCP server ON by driving Fusion's Preferences dialog (no API exists for the
/// toggle). The section click is image-space and takes the foreground, announced.
pub fn enable(ctx: &Ctx, version: &str, aps: (bool, bool), pass: &dyn Fn(&str, Value) -> Value) -> Value {
if ctx.host.fusion_processes().is_empty() { return json!({"success": false, "error": "Fusion is not running.", "_hint": "fusion_start first, then fusion_mcp_enable."}); }
if new_session(version)["error"].is_null() { return json!({"success": true, "enabled": true, "alreadyOn": true, "_hint": "Already enabled; nothing to do. fusion_mcp_status shows the tools."}); }
let opened = crate::maintenance::prefs_open(ctx, &json!({"section": "api"}), pass);
let mut steps: Vec<Value> = opened["steps"].as_array().cloned().unwrap_or_default();
if opened["success"].as_bool() != Some(true) { return json!({"success": false, "error": opened["error"], "steps": steps, "_hint": off_hint(aps)}); }
let Some(ab) = fusion_core::ab::Loopback::from_env() else { return json!({"success": false, "error": "ab loopback unavailable", "steps": steps}) };
use fusion_core::ab::AbClient;
let (hwnd, w, h) = (opened["hwnd"].as_i64().unwrap_or(0), opened["imageWidth"].as_f64().unwrap_or(1400.0), opened["imageHeight"].as_f64().unwrap_or(836.0));
let _ = ab.call("desktop_click", json!({"space": "image", "shotId": opened["shotId"], "hwnd": hwnd, "x": (w * 0.493) as i64, "y": (h * 0.569) as i64}), &ctx.caller, 30);
steps.push(json!("ticked 'Fusion MCP Server'"));
std::thread::sleep(Duration::from_secs(1));
let closed = crate::maintenance::prefs_close(ctx, &json!({"save": true}));
steps.push(json!("clicked Apply then OK"));
if closed["closed"].as_bool() != Some(true) { steps.push(json!("Preferences still open after Apply/OK")); }
std::thread::sleep(Duration::from_secs(4));
let s = new_session(version);
let ok = s["error"].is_null();
json!({"success": ok, "enabled": ok, "steps": steps, "sessionId": s["sessionId"], "serverInfo": s["serverInfo"],
"narrate": if ok { "Fusion's MCP server is on. I enabled it for you in Preferences." } else { "I drove the Preferences dialog but the MCP port is still closed." },
"_hint": if ok { "MCP server ENABLED - fusion_mcp_status lists its tools. Tell the user it is handled; they do not need to touch Preferences." } else { "The toggle did not take. Re-call fusion_mcp_enable - driving Preferences is racy on a busy main thread and a second pass usually lands it. Do NOT hand the user a Preferences walkthrough (describe.py says NEVER ask them to do this by hand); if a second pass also fails, report what the steps[] show and that the setting is discarded when Fusion restarts before Apply." },
"statusVerb": "fusion_mcp_status"})
}