Skip to main content

netget/cli/
server_startup.rs

1//! Server startup logic for TUI mode
2//!
3//! Handles spawning TCP and HTTP servers based on application state
4
5use anyhow::Result;
6use std::net::SocketAddr;
7use std::sync::Arc;
8use tokio::sync::mpsc;
9
10use crate::events::ActionExecutionError;
11use crate::llm::OllamaClient;
12use crate::protocol::metadata::DevelopmentState;
13use crate::state::app_state::AppState;
14use crate::state::ServerId;
15
16/// Format the refusal message when a protocol is below the operator's
17/// `--min-stability` floor.
18///
19/// Names the protocol, its actual declared state and the required minimum, so
20/// the reason is legible in the TUI, the status stream and any MCP tool error —
21/// mirroring how the privilege and dependency gates report a refusal. Shared
22/// with `client_startup` so servers and clients speak with one voice.
23pub(crate) fn min_stability_refusal(
24    kind: &str,
25    protocol: &str,
26    actual: DevelopmentState,
27    min: DevelopmentState,
28) -> String {
29    format!(
30        "Cannot start {protocol} {kind}: its stability is {} but --min-stability requires at least \
31         {}. Raise the protocol's maturity or lower --min-stability.",
32        actual.as_str(),
33        min.as_str()
34    )
35}
36
37/// Did `spawn()` actually bind a listening socket?
38///
39/// `ProtocolServer::spawn` returns a `SocketAddr` for every protocol, but not
40/// every protocol listens: WebRTC is peer-to-peer and returns a placeholder
41/// `0.0.0.0:0`, and a couple of others fall back to the same placeholder when
42/// they have no address to report. Port 0 is never a real bound port (the OS
43/// resolves it to a concrete one at bind time), so it is an exact test for the
44/// placeholder - and printing "listening on 0.0.0.0:0" for those servers told
45/// the user, the TUI and every log reader something untrue.
46fn is_bound_addr(addr: &SocketAddr) -> bool {
47    addr.port() != 0
48}
49
50/// Check if an error is due to address already in use
51fn is_addr_in_use_error(err: &anyhow::Error) -> bool {
52    if let Some(io_err) = err.downcast_ref::<std::io::Error>() {
53        io_err.kind() == std::io::ErrorKind::AddrInUse
54    } else {
55        false
56    }
57}
58
59/// Start a specific server by ID
60pub async fn start_server_by_id(
61    state: &AppState,
62    server_id: ServerId,
63    llm_client: &OllamaClient,
64    status_tx: &mpsc::UnboundedSender<String>,
65) -> Result<(), ActionExecutionError> {
66    // Get server info
67    let server = match state
68        .with_server_mut(server_id, |current| {
69            crate::protocol::StartupParams::preflight_owned(&mut current.startup_params)?;
70            Ok::<_, crate::protocol::StartupParamError>(current.clone())
71        })
72        .await
73    {
74        Some(Ok(current)) => current,
75        Some(Err(error)) => return Err(ActionExecutionError::Fatal(error.into())),
76        None => {
77            let _ = status_tx.send(format!("[ERROR] Server #{} not found", server_id.as_u32()));
78            return Ok(());
79        }
80    };
81
82    // Build listen address
83    let listen_addr: SocketAddr = format!("127.0.0.1:{}", server.port)
84        .parse()
85        .map_err(|e| ActionExecutionError::Fatal(anyhow::anyhow!("Invalid address: {}", e)))?;
86
87    let protocol_name = server.protocol_name.clone();
88
89    // Actually spawn the server using the registry
90    use crate::state::server::ServerStatus;
91
92    // Get protocol implementation from registry
93    let protocol = crate::protocol::server_registry::registry()
94        .resolve(&protocol_name)
95        .map_err(|e| anyhow::anyhow!("{e}"))?;
96
97    // Check privilege requirements before spawning
98    let metadata = protocol.metadata();
99
100    // === --min-stability gate ===
101    // Refuse a protocol below the operator's maturity floor before any spawn.
102    // This is the authoritative enforcement point: even if the LLM was somehow
103    // offered a forbidden protocol, it cannot be started.
104    if let Some(min) = state.get_min_stability().await {
105        if metadata.state < min {
106            let full_error = min_stability_refusal("server", &protocol_name, metadata.state, min);
107            state
108                .update_server_status(server_id, ServerStatus::Error(full_error.clone()))
109                .await;
110            let _ = status_tx.send(format!("[ERROR] {}", full_error));
111            let _ = status_tx.send("__UPDATE_UI__".to_string());
112            return Err(ActionExecutionError::Fatal(anyhow::anyhow!(full_error)));
113        }
114    }
115
116    let system_caps = state.get_system_capabilities().await;
117    // The requirement for this start: a protocol's unprivileged test transport needs none.
118    let privilege_requirement =
119        protocol.startup_privilege_requirement(server.startup_params.as_ref());
120
121    // Decide whether this start is blocked for lack of privilege.
122    //
123    // `is_met_by()` is the single source of truth: it maps each requirement onto
124    // the capability that actually satisfies it (`RawSockets` -> raw-socket
125    // access, `Root` -> root, `PrivilegedPort` -> privileged-port binding).
126    // Anything else ANDed in here silently overrides it - in particular, gating a
127    // `RawSockets` protocol on `can_bind_privileged_ports` let it through on a
128    // host that cannot open raw sockets.
129    let privilege_met = privilege_requirement.is_met_by(&system_caps);
130
131    let requires_privileges = match &privilege_requirement {
132        crate::protocol::metadata::PrivilegeRequirement::PrivilegedPort(_) => {
133            // Only require privileges if actually binding to a privileged port.
134            // Port 0 means OS-assigned port, which will always be unprivileged (>1024)
135            server.port > 0 && server.port < 1024 && !privilege_met
136        }
137        // RawSockets, Root, None: the requirement itself is the whole test.
138        _ => !privilege_met,
139    };
140
141    if requires_privileges {
142        let error_msg = format!(
143            "Cannot start {} server on port {}: {}. Current capabilities: {}",
144            protocol_name,
145            server.port,
146            privilege_requirement.description(),
147            system_caps.description()
148        );
149
150        // Provide helpful suggestion based on platform
151        let suggestion = if cfg!(target_os = "linux") {
152            match &privilege_requirement {
153                crate::protocol::metadata::PrivilegeRequirement::PrivilegedPort(port) => {
154                    format!("\nSuggestion: Run as root (sudo) or use a port >= 1024 (e.g., {}, {}, {})",
155                        port + 8000, port + 10000, 8080)
156                }
157                crate::protocol::metadata::PrivilegeRequirement::RawSockets => {
158                    "\nSuggestion: Run as root or grant CAP_NET_RAW: sudo setcap cap_net_raw+ep /path/to/netget".to_string()
159                }
160                crate::protocol::metadata::PrivilegeRequirement::Root => {
161                    "\nSuggestion: Run as root (sudo netget ...)".to_string()
162                }
163                _ => String::new(),
164            }
165        } else if cfg!(target_os = "macos") {
166            "\nSuggestion: Run as root (sudo netget ...)".to_string()
167        } else {
168            "\nSuggestion: Run as Administrator".to_string()
169        };
170
171        let full_error = format!("{}{}", error_msg, suggestion);
172
173        state
174            .update_server_status(server_id, ServerStatus::Error(full_error.clone()))
175            .await;
176        let _ = status_tx.send(format!("[ERROR] {}", full_error));
177        let _ = status_tx.send("__UPDATE_UI__".to_string());
178        return Err(ActionExecutionError::PrivilegeDenied {
179            requirement: privilege_requirement.description(),
180            message: full_error,
181        });
182    }
183
184    // Non-privilege dependencies: a missing system library or tool in PATH.
185    //
186    // `Protocol::get_dependencies()` derives from `privilege_requirement`, which the block
187    // above already enforced — re-checking those here would report the same failure twice with
188    // a worse message. What it cannot express is a library or binary the protocol needs at
189    // runtime, which a protocol supplies by overriding `get_dependencies()`. Those had no gate
190    // at all: the TUI and the model's protocol list would exclude such a protocol while a
191    // direct `start_server` still attempted it and failed with whatever raw error the
192    // underlying library produced.
193    //
194    // Only definitively-unmet dependencies refuse (`dependencies::startup_blocker`): a probe
195    // that could not answer is logged and let through. `DeviceAccess` deliberately derives no
196    // dependency because no probe can answer honestly for an adapter or reader, and this gate
197    // must never turn "we could not tell" into "no".
198    let blocker = crate::protocol::dependencies::startup_blocker(
199        &protocol.startup_dependencies(server.startup_params.as_ref()),
200        &system_caps,
201    );
202
203    if let Some(dep) = blocker {
204        let full_error = format!(
205            "Cannot start {} server on port {}: {}. {}",
206            protocol_name,
207            server.port,
208            dep.description(),
209            dep.installation_hint()
210        );
211
212        state
213            .update_server_status(server_id, ServerStatus::Error(full_error.clone()))
214            .await;
215        let _ = status_tx.send(format!("[ERROR] {}", full_error));
216        let _ = status_tx.send("__UPDATE_UI__".to_string());
217        return Err(ActionExecutionError::PrivilegeDenied {
218            requirement: dep.name(),
219            message: full_error,
220        });
221    }
222
223    // Build type-safe startup params if provided
224    //
225    // The JSON comes from the LLM or an MCP client, so a bad key must become a
226    // reported error (and an `Error` server status), never a panic.
227    let startup_params = if let Some(params_json) = server.startup_params.clone() {
228        // Get the parameter schema from the protocol
229        let schema = protocol.get_startup_parameters();
230        // Create validated StartupParams
231        match crate::protocol::StartupParams::new_validated(params_json, schema) {
232            Ok(p) => Some(p),
233            Err(e) => {
234                let msg = format!("Invalid startup_params for {}: {}", protocol_name, e);
235                state
236                    .update_server_status(server_id, ServerStatus::Error(msg.clone()))
237                    .await;
238                let _ = status_tx.send(format!("[ERROR] {}", msg));
239                let _ = status_tx.send("__UPDATE_UI__".to_string());
240                return Err(ActionExecutionError::Fatal(anyhow::anyhow!(msg)));
241            }
242        }
243    } else {
244        None
245    };
246
247    // Build spawn context
248    // NOTE: This is the legacy path for unmigrated protocols
249    // Migrated protocols should be started via start_server_from_action
250    #[allow(deprecated)]
251    let spawn_ctx = crate::protocol::SpawnContext {
252        listen_addr,
253        mac_address: None,
254        interface: None,
255        host: None,
256        port: None,
257        // Attach the per-server status channel so event-template lifecycle logs
258        // (rendered via EventLogContext) reach the TUI, not just netget.log.
259        llm_client: llm_client.clone().with_status_tx(status_tx.clone()),
260        state: Arc::new(state.clone()),
261        status_tx: status_tx.clone(),
262        server_id,
263        startup_params,
264    };
265
266    // Spawn the server using the protocol's spawn method
267    match protocol.spawn(spawn_ctx).await {
268        Ok(actual_addr) => {
269            let bound = is_bound_addr(&actual_addr);
270
271            // Send startup message with actual port
272            let msg = if bound {
273                format!(
274                    "[SERVER] Starting server #{} ({}) on {}",
275                    server_id.as_u32(),
276                    protocol_name,
277                    actual_addr
278                )
279            } else {
280                format!(
281                    "[SERVER] Starting server #{} ({}) (no listening socket)",
282                    server_id.as_u32(),
283                    protocol_name
284                )
285            };
286            let _ = status_tx.send(msg);
287
288            // Update server with actual listen address (only if one was bound;
289            // recording the 0.0.0.0:0 placeholder would surface it in the TUI
290            // and in server_status as though it were an endpoint)
291            if bound {
292                state.update_server_local_addr(server_id, actual_addr).await;
293            }
294            state
295                .update_server_status(server_id, ServerStatus::Running)
296                .await;
297            // Send update message with actual bound address (for tests that use port 0)
298            if bound && (server.port == 0 || server.port != actual_addr.port()) {
299                let update_msg = format!(
300                    "[SERVER] Server #{} ({}) listening on {}",
301                    server_id.as_u32(),
302                    protocol_name,
303                    actual_addr
304                );
305                let _ = status_tx.send(update_msg);
306            }
307            // Note: protocol-specific "listening on" message is also sent by the protocol's spawn method to tracing
308            let _ = status_tx.send("__UPDATE_UI__".to_string());
309        }
310        Err(e) => {
311            // Check if error is due to port already in use
312            if is_addr_in_use_error(&e) {
313                // Return retryable error with context for LLM
314                let _ = status_tx.send(format!(
315                    "[INFO] Port {} is already in use for {} server, will retry with LLM suggestion",
316                    server.port,
317                    protocol_name
318                ));
319                return Err(ActionExecutionError::PortConflict {
320                    port: server.port,
321                    protocol: protocol_name.clone(),
322                    underlying_error: e.to_string(),
323                });
324            }
325
326            // For other errors, fail immediately
327            state
328                .update_server_status(server_id, ServerStatus::Error(e.to_string()))
329                .await;
330            let _ = status_tx.send(format!(
331                "[ERROR] Failed to start {} server #{}: {}",
332                protocol_name,
333                server_id.as_u32(),
334                e
335            ));
336            let _ = status_tx.send("__UPDATE_UI__".to_string());
337            return Err(ActionExecutionError::Fatal(e));
338        }
339    }
340
341    Ok(())
342}
343
344/// Start a server from action parameters (used by /load command)
345/// Returns the server ID on success
346#[allow(clippy::too_many_arguments)]
347/// The address a protocol without `default_binding()` listens on: loopback by default, the
348/// caller's host when one is named. `localhost` is accepted as loopback; any other name is
349/// refused, because this path binds an address and never resolves one.
350pub fn legacy_bind_ip(host: Option<&str>) -> Result<std::net::IpAddr> {
351    use std::net::{IpAddr, Ipv4Addr};
352    match host.map(str::trim) {
353        None | Some("") | Some("localhost") => Ok(IpAddr::V4(Ipv4Addr::LOCALHOST)),
354        Some(h) => h
355            .trim_start_matches('[')
356            .trim_end_matches(']')
357            .parse()
358            .map_err(|_| anyhow::anyhow!("host {h:?} is not an IP address (or \"localhost\")")),
359    }
360}
361
362pub async fn start_server_from_action(
363    state: &AppState,
364    // NEW: Flexible binding parameters (all optional)
365    mac_address: Option<String>,
366    interface: Option<String>,
367    host: Option<String>,
368    port: Option<u16>,
369    protocol: &str,
370    send_first: bool,
371    initial_memory: Option<String>,
372    instruction: String,
373    mut startup_params: Option<serde_json::Value>,
374    event_handlers: Option<Vec<serde_json::Value>>,
375    scheduled_tasks: Option<Vec<crate::llm::actions::common::ServerTaskDefinition>>,
376    feedback_instructions: Option<String>,
377    status_tx: mpsc::UnboundedSender<String>,
378) -> Result<ServerId> {
379    use crate::state::server::ServerStatus;
380    crate::protocol::StartupParams::preflight_owned(&mut startup_params)?;
381    let prepared_tasks = crate::state::task::prepare_tasks(
382        scheduled_tasks.as_deref(),
383        crate::state::task::TaskScope::Global,
384    )?;
385
386    // Resolve the protocol from the registry. `resolve()` is case-insensitive and
387    // distinguishes "compiled out of this build" from "no such protocol", with a
388    // did-you-mean suggestion for the latter.
389    let registry = crate::protocol::server_registry::registry();
390    let protocol_impl = registry
391        .resolve(protocol)
392        .map_err(|e| anyhow::anyhow!("{e}"))?;
393
394    // === --min-stability gate ===
395    // Refuse a protocol below the operator's maturity floor before building
396    // anything — no server instance exists yet, so there is nothing to strand.
397    if let Some(min) = state.get_min_stability().await {
398        let actual = protocol_impl.metadata().state;
399        if actual < min {
400            let msg = min_stability_refusal("server", protocol, actual, min);
401            let _ = status_tx.send(format!("[ERROR] {}", msg));
402            return Err(anyhow::anyhow!(msg));
403        }
404    }
405
406    // === Default port ===
407    //
408    // Only when the caller named no port at all. An explicit port — `0` included — is the
409    // caller's decision and is never replaced. Without a port, a protocol with a well-known
410    // port takes it when this process can bind it and nothing already holds it, and otherwise
411    // starts on an OS-assigned port and says why; see `protocol::default_port`. A protocol with
412    // no well-known port and no `default_binding()` gets 0, so the unmigrated path below always
413    // receives a port.
414    let port = match port {
415        Some(explicit) => Some(explicit),
416        None => {
417            let caps = state.get_system_capabilities().await;
418            match crate::protocol::default_port::default_port_for_server(
419                protocol_impl.as_ref(),
420                host.as_deref(),
421                &caps,
422            ) {
423                Some(default) => {
424                    let _ = status_tx.send(format!(
425                        "[INFO] {} server: no port given; {}",
426                        protocol,
427                        default.describe()
428                    ));
429                    Some(default.port)
430                }
431                None => None,
432            }
433        }
434    };
435
436    // === send_first ===
437    //
438    // `send_first` is a top-level parameter of this function (and of the
439    // `open_server` action / MCP `start_server` tool), but protocols consume it
440    // as a startup parameter: TCP, Redis, PostgreSQL, MySQL, MongoDB, JSON-RPC,
441    // IRC and LDAP all read `startup_params.send_first`. Fold the top-level flag
442    // into `startup_params` so the two spellings mean the same thing.
443    //
444    // Only inject when the protocol actually declares the parameter -
445    // `StartupParams` rejects undeclared keys. An explicit value already present
446    // in `startup_params` wins, so callers can still set it per-protocol.
447    let declares_send_first = protocol_impl
448        .get_startup_parameters()
449        .iter()
450        .any(|p| p.name == "send_first");
451
452    let mut startup_params = if send_first && declares_send_first {
453        let mut params = startup_params.unwrap_or_else(|| serde_json::json!({}));
454        match params.as_object_mut() {
455            Some(map) => {
456                map.entry("send_first")
457                    .or_insert(serde_json::Value::Bool(true));
458                Some(params)
459            }
460            None => {
461                return Err(anyhow::anyhow!(
462                    "startup_params must be a JSON object, got: {}",
463                    crate::utils::redact::redact_sensitive(&params)
464                ));
465            }
466        }
467    } else {
468        if send_first && !declares_send_first {
469            let _ = status_tx.send(format!(
470                "[WARN] Protocol '{}' does not support send_first; ignoring it",
471                protocol
472            ));
473            tracing::warn!(
474                "send_first requested for protocol '{}', which declares no send_first startup parameter - ignoring",
475                protocol
476            );
477        }
478        startup_params
479    };
480
481    crate::protocol::StartupParams::preflight_owned(&mut startup_params)?;
482
483    // === Validate startup params BEFORE registering the server ===
484    //
485    // This JSON comes straight from the LLM (`open_server`) or an MCP client
486    // (`start_server`), so it is untrusted. Validating here means a malformed
487    // request returns an error to the caller and leaves no `ServerInstance`
488    // stranded in `ServerStatus::Starting`.
489    let startup_params_obj = match startup_params.clone() {
490        Some(params_json) => {
491            let schema = protocol_impl.get_startup_parameters();
492            crate::cli::management::validate_server_startup_params(
493                &params_json,
494                &schema,
495                protocol,
496            )?;
497            Some(
498                crate::protocol::StartupParams::new_validated(params_json, schema).map_err(
499                    |e| anyhow::anyhow!("Invalid startup_params for {}: {}", protocol, e),
500                )?,
501            )
502        }
503        None => {
504            crate::protocol::StartupParams::new_validated(
505                serde_json::json!({}),
506                protocol_impl.get_startup_parameters(),
507            )?;
508            None
509        }
510    };
511
512    // === DUAL PATH LOGIC: Migrated vs Unmigrated Protocols ===
513    //
514    // Migrated protocols return Some(...) from default_binding()
515    // Unmigrated protocols return None and use legacy listen_addr
516    //
517    let (final_mac, final_interface, final_host, final_port, _use_new_path, listen_addr) =
518        if let Some(defaults) = protocol_impl.default_binding() {
519            // NEW PATH: Protocol has been migrated, use flexible binding
520            let (mac, iface, host_str, port_num) =
521                defaults.apply(mac_address.clone(), interface.clone(), host.clone(), port);
522
523            // Port 0 is passed straight through to the protocol's own bind, so the OS
524            // assigns it atomically.
525            //
526            // This used to bind a probe listener, read the port it was given, drop it, and
527            // let the protocol bind that same port a moment later. Between the drop and
528            // the real bind the port belonged to nobody, so a concurrent starter could
529            // take it -- under a hundred parallel tests `ServerForm::create` failed every
530            // few runs with "Address already in use" for a request that asked for port 0,
531            // which is the one request that should never be able to collide.
532            //
533            // Nothing downstream needs the number early: `spawn()` returns the bound
534            // address, `update_server_local_addr` records it, and the "listening on"
535            // status line is already emitted whenever the requested port was 0.
536            let final_port_num = port_num;
537
538            // Construct legacy listen_addr for backwards compatibility
539            // (protocols still receive this field, but new protocols should ignore it)
540            let legacy_addr = match (&host_str, final_port_num) {
541                (Some(h), Some(p)) => format!("{}:{}", h, p)
542                    .parse()
543                    .unwrap_or_else(|_| "127.0.0.1:0".parse().unwrap()),
544                _ => "127.0.0.1:0".parse().unwrap(),
545            };
546
547            (mac, iface, host_str, final_port_num, true, legacy_addr)
548        } else {
549            // OLD PATH: Protocol hasn't been migrated, use backwards-compatible behavior
550            // Port field is required for unmigrated protocols
551            let port_value = port.ok_or_else(|| {
552                anyhow::anyhow!(
553                    "Protocol '{}' requires 'port' parameter (unmigrated protocol)",
554                    protocol
555                )
556            })?;
557
558            // Port 0 goes through to the protocol's bind, same as the migrated path
559            // above and for the same reason: resolving it here and binding it later loses
560            // the port to whoever asks in between.
561            let actual_port = port_value;
562
563            // Loopback unless the caller named a host. An explicit host used to be dropped
564            // here without a word, so `host: "0.0.0.0"` quietly bound 127.0.0.1; a host that
565            // is not an address is refused rather than replaced.
566            let ip = legacy_bind_ip(host.as_deref())?;
567            let listen_addr = SocketAddr::new(ip, actual_port);
568
569            (
570                None,
571                None,
572                Some(ip.to_string()),
573                Some(actual_port),
574                false,
575                listen_addr,
576            )
577        };
578
579    // Check privilege requirements — for this start, so an unprivileged test transport is
580    // not refused for the privilege its real transport needs.
581    let privilege_requirement =
582        protocol_impl.startup_privilege_requirement(startup_params.as_ref());
583    let system_caps = state.get_system_capabilities().await;
584
585    // Decide whether this start is blocked for lack of privilege.
586    //
587    // As in `start_server_by_id`, `is_met_by()` is the only test: it already maps
588    // each requirement onto the capability that satisfies it. ANDing an unrelated
589    // capability on top (e.g. `can_bind_privileged_ports` for a `RawSockets`
590    // protocol) let unprivileged starts through.
591    let privilege_met = privilege_requirement.is_met_by(&system_caps);
592
593    let requires_privileges = match &privilege_requirement {
594        crate::protocol::metadata::PrivilegeRequirement::PrivilegedPort(_) => {
595            // Only require privileges if actually binding to a privileged port.
596            // Port 0 means OS-assigned port, which will always be unprivileged (>1024)
597            // final_port has already been resolved to an actual port number
598            let privileged_port = match final_port {
599                Some(p) => p > 0 && p < 1024,
600                None => false, // Interface-based protocols don't use ports
601            };
602            privileged_port && !privilege_met
603        }
604        // RawSockets, Root, None: the requirement itself is the whole test.
605        _ => !privilege_met,
606    };
607
608    if requires_privileges {
609        let error_msg = format!(
610            "Cannot start {} server: {}. Current capabilities: {}",
611            protocol,
612            privilege_requirement.description(),
613            system_caps.description()
614        );
615        return Err(anyhow::anyhow!(error_msg));
616    }
617
618    // Runtime dependencies privilege cannot express — a system library, a binary on PATH.
619    //
620    // Refused here, before `add_server`, like privilege and --min-stability: a server that
621    // cannot start is never registered, so no `Error` row is stranded in `list_servers` (the
622    // same reason a failed `spawn()` below removes its row), and the caller — the MCP tool
623    // error, the model's `open_server` result, a non-interactive run's exit status — gets the
624    // dependency and its install hint instead of whatever the library says part-way through
625    // spawning. Only a dependency the probe established is absent refuses; see
626    // `dependencies::startup_blocker`.
627    if let Some(dep) = crate::protocol::dependencies::startup_blocker(
628        &protocol_impl.startup_dependencies(startup_params.as_ref()),
629        &system_caps,
630    ) {
631        let msg = format!(
632            "Cannot start {} server: {}. {}",
633            protocol,
634            dep.description(),
635            dep.installation_hint()
636        );
637        let _ = status_tx.send(format!("[ERROR] {}", msg));
638        return Err(anyhow::anyhow!(msg));
639    }
640
641    let parsed_handlers = event_handlers
642        .map(crate::events::handler::EventHandler::parse_event_handlers)
643        .transpose()
644        .map_err(|error| anyhow::anyhow!("Invalid event handler configuration: {}", error))?;
645
646    // Create server instance
647    // NOTE: For unmigrated protocols, port is always Some(_)
648    // For migrated protocols, port may be None (interface-based protocols like ICMP)
649    let display_port = final_port.unwrap_or(0);
650
651    let server = crate::state::server::ServerInstance {
652        id: ServerId::new(0), // Will be assigned by add_server
653        port: display_port,
654        protocol_name: protocol.to_string(),
655        instruction: instruction.clone(),
656        memory: String::new(),
657        status: ServerStatus::Starting,
658        connections: Default::default(),
659        local_addr: None,
660        created_at: crate::utils::clock::Instant::now(),
661        status_changed_at: crate::utils::clock::Instant::now(),
662        startup_params: startup_params.clone(),
663        event_handler_config: None,
664        protocol_data: serde_json::Value::Null,
665        log_files: Default::default(),
666        feedback_instructions,
667        feedback_buffer: Vec::new(),
668        last_feedback_processed: None,
669        recent_connections: Default::default(),
670        connection_opened_at: Default::default(),
671    };
672
673    let server_id = state.add_server(server).await;
674    let mut startup_guard = super::startup_guard::StartupGuard::server(state, server_id);
675
676    // Set initial memory if provided
677    if let Some(mem) = initial_memory {
678        state.set_memory(server_id, mem).await;
679    }
680
681    if let Some(config) = parsed_handlers {
682        state
683            .with_server_mut(server_id, |server| {
684                server.event_handler_config = Some(config)
685            })
686            .await;
687        let _ = status_tx.send("[INFO] Event handler configuration applied".to_string());
688    }
689
690    // Definitions were prepared before the owner was registered.
691    for (task_def, mut task) in scheduled_tasks
692        .unwrap_or_default()
693        .into_iter()
694        .zip(prepared_tasks)
695    {
696        task.scope = crate::state::task::TaskScope::Server(server_id);
697        let task_name = task.name.clone();
698        let task_id_num = state.add_task(task).await;
699        // Report it exactly as the standalone `schedule_task` action does
700        // (src/events/handler.rs). Creating a task through `open_server`'s
701        // `scheduled_tasks` array used to be completely silent - nothing in
702        // the TUI, nothing over MCP - so the two paths disagreed about
703        // whether task creation is an observable event. It is.
704        if task_def.recurring {
705            let interval = task_def.interval_secs.unwrap_or(60);
706            let max_info = match task_def.max_executions {
707                Some(max) => format!(" (max {} executions)", max),
708                None => String::new(),
709            };
710            let msg = format!(
711                "[TASK] Scheduled recurring task '{}' (ID: {}) to execute every {}s{}",
712                task_name, task_id_num, interval, max_info
713            );
714            tracing::info!("{}", msg);
715            let _ = status_tx.send(msg);
716        } else {
717            let msg = format!(
718                "[TASK] Scheduled one-shot task '{}' (ID: {}) to execute in {}s",
719                task_name,
720                task_id_num,
721                task_def.delay_secs.unwrap_or(0)
722            );
723            tracing::info!("{}", msg);
724            let _ = status_tx.send(msg);
725        }
726    }
727
728    // `startup_params_obj` was built and validated above, before `add_server`.
729
730    // Build spawn context
731    // Use the configured LLM client from state (includes mock config, lock settings, etc.)
732    // If not available (shouldn't happen), fall back to creating a new client
733    let llm_client = if let Some(client) = state.get_llm_client().await {
734        client
735    } else {
736        #[cfg(not(target_arch = "wasm32"))]
737        {
738            let ollama_url = state.get_ollama_url().await;
739            OllamaClient::new(ollama_url)
740        }
741        #[cfg(target_arch = "wasm32")]
742        {
743            anyhow::bail!("no LLM client is configured; the browser build installs one at startup")
744        }
745    }
746    // Attach the per-server status channel so event-template lifecycle logs
747    // (rendered via EventLogContext) reach the TUI, not just netget.log.
748    .with_status_tx(status_tx.clone());
749
750    #[allow(deprecated)]
751    let spawn_ctx = crate::protocol::SpawnContext {
752        listen_addr,
753        mac_address: final_mac,
754        interface: final_interface,
755        host: final_host,
756        port: final_port,
757        llm_client,
758        state: Arc::new(state.clone()),
759        status_tx: status_tx.clone(),
760        server_id,
761        startup_params: startup_params_obj,
762    };
763
764    // Spawn the server
765    match protocol_impl.spawn(spawn_ctx).await {
766        Ok(actual_addr) => {
767            let bound = is_bound_addr(&actual_addr);
768
769            // Send startup message with actual address
770            let msg = if bound {
771                format!(
772                    "[SERVER] Starting server #{} ({}) on {}",
773                    server_id.as_u32(),
774                    protocol,
775                    actual_addr
776                )
777            } else {
778                format!(
779                    "[SERVER] Starting server #{} ({}) (no listening socket)",
780                    server_id.as_u32(),
781                    protocol
782                )
783            };
784            let _ = status_tx.send(msg);
785
786            // Update server with actual listen address (only if one was bound;
787            // see is_bound_addr)
788            if bound {
789                state.update_server_local_addr(server_id, actual_addr).await;
790            }
791            state
792                .update_server_status(server_id, ServerStatus::Running)
793                .await;
794
795            // Send update message with actual bound address (for tests that use port 0)
796            if bound
797                && (final_port.unwrap_or(0) == 0 || final_port.unwrap_or(0) != actual_addr.port())
798            {
799                let update_msg = format!(
800                    "[SERVER] Server #{} ({}) listening on {}",
801                    server_id.as_u32(),
802                    protocol,
803                    actual_addr
804                );
805                let _ = status_tx.send(update_msg);
806            }
807
808            startup_guard.disarm();
809            Ok(server_id)
810        }
811        Err(e) => {
812            // The server never came up, so nothing about it is inspectable or
813            // controllable: it has no listener, no connections and no task. Leaving
814            // the instance registered only makes `list_servers` accumulate zombie
815            // `Error` rows that can never transition anywhere, each holding a
816            // ServerId and any log files it opened. The error is returned to the
817            // caller (and, for MCP, becomes the tool error), so dropping the
818            // registration loses nothing.
819            state
820                .update_server_status(server_id, ServerStatus::Error(e.to_string()))
821                .await;
822            state.remove_server(server_id).await;
823            startup_guard.disarm();
824            let _ = status_tx.send(format!(
825                "[ERROR] Server #{} ({}) failed to start: {}",
826                server_id.as_u32(),
827                protocol,
828                e
829            ));
830            let _ = status_tx.send("__UPDATE_UI__".to_string());
831            Err(e)
832        }
833    }
834}