Skip to main content

netget/mcp_stdio/
tools.rs

1//! MCP tool definitions and handlers for the STDIO server
2//!
3//! Each tool maps to existing NetGet functionality (server management, protocol listing, etc.)
4//! Tools use rmcp's macro system for automatic schema generation.
5
6use std::sync::Arc;
7use tokio::sync::mpsc;
8
9use rmcp::handler::server::{router::tool::ToolRouter, wrapper::Parameters};
10use rmcp::model::{
11    CallToolResult, Content, Implementation, InitializeRequestParams, InitializeResult,
12    ServerCapabilities,
13};
14use rmcp::service::{RequestContext, RoleServer};
15use rmcp::{tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler};
16use schemars::JsonSchema;
17use serde::{Deserialize, Deserializer};
18use tracing::info;
19
20/// Deserialize Option<u16> from either a number or a string (MCP clients may send "6178" instead of 6178)
21fn deserialize_option_u16_flexible<'de, D>(deserializer: D) -> Result<Option<u16>, D::Error>
22where
23    D: Deserializer<'de>,
24{
25    #[derive(Deserialize)]
26    #[serde(untagged)]
27    enum NumOrString {
28        Number(u16),
29        String(String),
30    }
31    match Option::<NumOrString>::deserialize(deserializer)? {
32        Some(NumOrString::Number(n)) => Ok(Some(n)),
33        Some(NumOrString::String(s)) => s.parse().map(Some).map_err(serde::de::Error::custom),
34        None => Ok(None),
35    }
36}
37
38/// Deserialize u32 from either a number or a string
39fn deserialize_u32_flexible<'de, D>(deserializer: D) -> Result<u32, D::Error>
40where
41    D: Deserializer<'de>,
42{
43    #[derive(Deserialize)]
44    #[serde(untagged)]
45    enum NumOrString {
46        Number(u32),
47        String(String),
48    }
49    match NumOrString::deserialize(deserializer)? {
50        NumOrString::Number(n) => Ok(n),
51        NumOrString::String(s) => s.parse().map_err(serde::de::Error::custom),
52    }
53}
54
55/// Deserialize u64 from either a number or a string
56fn deserialize_u64_flexible<'de, D>(deserializer: D) -> Result<u64, D::Error>
57where
58    D: Deserializer<'de>,
59{
60    #[derive(Deserialize)]
61    #[serde(untagged)]
62    enum NumOrString {
63        Number(u64),
64        String(String),
65    }
66    match NumOrString::deserialize(deserializer)? {
67        NumOrString::Number(n) => Ok(n),
68        NumOrString::String(s) => s.parse().map_err(serde::de::Error::custom),
69    }
70}
71
72/// Deserialize Option<u32> from either a number or a string
73fn deserialize_option_u32_flexible<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
74where
75    D: Deserializer<'de>,
76{
77    #[derive(Deserialize)]
78    #[serde(untagged)]
79    enum NumOrString {
80        Number(u32),
81        String(String),
82    }
83    match Option::<NumOrString>::deserialize(deserializer)? {
84        Some(NumOrString::Number(n)) => Ok(Some(n)),
85        Some(NumOrString::String(s)) => s.parse().map(Some).map_err(serde::de::Error::custom),
86        None => Ok(None),
87    }
88}
89
90use crate::cli::Args;
91use crate::llm::agent_queue::LlmRequestQueue;
92use crate::llm::OllamaClient;
93use crate::settings::Settings;
94use crate::state::app_state::AppState;
95
96/// Shared state for the MCP server
97///
98/// One instance is shared across all MCP sessions (a single session for STDIO,
99/// potentially many for the HTTP transport).
100pub(crate) struct SharedState {
101    app_state: AppState,
102    llm_client: OllamaClient,
103    /// Present only in `--llm-agent` mode: the queue protocol servers submit LLM
104    /// requests to and the MCP tools answer from.
105    agent_queue: Option<Arc<LlmRequestQueue>>,
106    _status_tx: mpsc::UnboundedSender<String>,
107    background_tasks: Vec<tokio::task::JoinHandle<()>>,
108    task_owner: tokio_util::sync::CancellationToken,
109    // On drop the token requests a graceful finish of the current claim/spawn
110    // batch. Aborting that batch could leave claimed tasks without an executor.
111    _task_ticker: tokio::task::JoinHandle<()>,
112}
113
114impl Drop for SharedState {
115    fn drop(&mut self) {
116        self.task_owner.cancel();
117        for task in &self.background_tasks {
118            task.abort();
119        }
120    }
121}
122
123/// NetGet MCP STDIO service - exposes NetGet capabilities as MCP tools
124#[derive(Clone)]
125pub struct NetGetMcpService {
126    state: Arc<SharedState>,
127    tool_router: ToolRouter<Self>,
128}
129
130// === Tool parameter structs ===
131
132#[derive(Debug, Deserialize, JsonSchema)]
133pub struct ListProtocolsParams {
134    /// Filter by "server", "client", or "all"
135    #[serde(default = "default_all")]
136    pub r#type: Option<String>,
137    /// Include experimental/honeypot protocols
138    #[serde(default)]
139    pub include_disabled: Option<bool>,
140}
141
142fn default_all() -> Option<String> {
143    Some("all".to_string())
144}
145
146#[derive(Debug, Deserialize, JsonSchema)]
147pub struct StartServerParams {
148    /// Protocol name. Use list_protocols tool to see all available protocols.
149    pub protocol: String,
150    /// Port to listen on. Omit it for the protocol's well-known port (6379 for redis, 53 for
151    /// dns); when that needs root this process lacks, or is already in use, the server starts
152    /// on an OS-assigned port and the result says why. Pass 0 for an OS-assigned port. An
153    /// explicit port, 0 included, is always used as given.
154    #[serde(default, deserialize_with = "deserialize_option_u16_flexible")]
155    pub port: Option<u16>,
156    /// Natural language instruction for the LLM that handles each request.
157    /// This is the LLM FALLBACK path: every request/event triggers a (slow,
158    /// billable) model call. Use it only when responses genuinely require
159    /// reasoning or vary unpredictably. For deterministic behavior (echo,
160    /// fixed/canned responses, simple routing, high throughput) prefer
161    /// `event_handlers` with a script or static handler instead — see below.
162    #[serde(default)]
163    pub instruction: Option<String>,
164    /// Host address to bind to (default: 127.0.0.1)
165    #[serde(default)]
166    pub host: Option<String>,
167    /// Event handlers that decide how incoming events are handled WITHOUT an LLM
168    /// call. Strongly preferred over `instruction` whenever the behavior is
169    /// deterministic — a script/static handler runs in-process (instant, free,
170    /// deterministic), while `instruction` pays a model round-trip per request.
171    ///
172    /// Array of objects: { "event_pattern": "<event id>" | "*", "handler": {...} }
173    /// matched in order, first match wins. `handler` is one of:
174    ///   - {"type":"script","language":"python"|"javascript","code":"..."} — runs
175    ///     the script per event. The event is JSON on stdin: read the payload via
176    ///     data['event'][<field>] and print {"actions":[ ... ]} to stdout. Field
177    ///     names vary by protocol (e.g. Telnet event `telnet_message_received` has
178    ///     `message`; HTTP `http_request` has `method`/`path`/`headers`/`body`).
179    ///   - {"type":"static","actions":[ ... ]} — always emit these fixed actions.
180    ///   - {"type":"llm","instruction":"..."} — fall back to the LLM (reasoning).
181    ///
182    /// Example — a Telnet echo server with ZERO LLM calls (echoes each line back
183    /// verbatim):
184    ///   [{"event_pattern":"telnet_message_received","handler":{"type":"script",
185    ///     "language":"python",
186    ///     "code":"import json,sys;d=json.load(sys.stdin);print(json.dumps({'actions':[{'type':'send_telnet_message','message':d['event']['message']}]}))"}}]
187    /// Always confirm a protocol's exact event ids, field names, and action names
188    /// with get_protocol_docs before writing a handler.
189    #[serde(default)]
190    pub event_handlers: Option<Vec<serde_json::Value>>,
191    /// Optional protocol-specific startup parameters (JSON object). For example,
192    /// HTTP accepts a `request_filter` (array of {methods, path regex, headers}
193    /// rules — only matching requests reach the LLM; the rest get `filtered_response`,
194    /// default 404) and a `filtered_response` ({status, body, headers}). Use
195    /// get_protocol_docs for a protocol's available startup parameters.
196    #[serde(default)]
197    pub startup_params: Option<serde_json::Value>,
198    /// Network interface to bind, for interface-bound (layer 2 / raw socket)
199    /// protocols such as `arp`, `datalink`, `icmp` and `isis` — e.g. "en0", "lo".
200    /// Ignored by port-based protocols. Check get_protocol_docs: if the protocol
201    /// is interface-bound its docs list `interface` instead of `port`.
202    #[serde(default)]
203    pub interface: Option<String>,
204    /// Source MAC address for layer 2 protocols (e.g. "02:00:00:00:00:01").
205    /// Only meaningful together with `interface`.
206    #[serde(default)]
207    pub mac_address: Option<String>,
208    /// Make the server speak first on connect instead of waiting for the client
209    /// — the FTP/SMTP-style greeting banner. Only protocols that declare a
210    /// `send_first` startup parameter honour it (tcp, redis, postgresql, mysql,
211    /// mongodb, jsonrpc, irc, ldap, …); for the rest it is ignored with a
212    /// warning. The banner content comes from the handler for the protocol's
213    /// "connection opened" event.
214    #[serde(default)]
215    pub send_first: Option<bool>,
216    /// Seed the server's LLM memory before the first request. Only affects the
217    /// `instruction` path; `event_handlers` never read memory.
218    #[serde(default)]
219    pub initial_memory: Option<String>,
220    /// Natural-language instructions for the automatic feedback loop that
221    /// adjusts this server's behaviour as it runs.
222    #[serde(default)]
223    pub feedback_instructions: Option<String>,
224    /// Server-scoped scheduled LLM tasks, removed when the server stops. Array of
225    /// objects: {"task_id":"...", "recurring":true, "interval_secs":60,
226    /// "instruction":"..."} for a repeating task, or {"task_id":"...",
227    /// "recurring":false, "delay_secs":10, "instruction":"..."} for a one-shot.
228    /// Optional: "max_executions" (number), "context" (any JSON).
229    #[serde(default)]
230    pub scheduled_tasks: Option<Vec<serde_json::Value>>,
231}
232
233#[derive(Debug, Deserialize, JsonSchema)]
234pub struct StartClientParams {
235    /// Protocol name. Use list_protocols with type "client" to see what is
236    /// available in this build, and get_protocol_docs for a protocol's event ids,
237    /// actions and startup parameters.
238    pub protocol: String,
239    /// Remote server to connect to, as "host:port" (e.g. "127.0.0.1:6379").
240    pub remote_addr: String,
241    /// Natural language instruction for the LLM that handles each event on this
242    /// connection. This is the LLM FALLBACK path: every event triggers a (slow,
243    /// billable) model call. Prefer `event_handlers` whenever the behavior is
244    /// deterministic.
245    #[serde(default)]
246    pub instruction: Option<String>,
247    /// Event handlers that decide how events are handled WITHOUT an LLM call.
248    /// Same shape as start_server's: an array of
249    /// { "event_pattern": "<event id>" | "*", "handler": {...} }, matched in
250    /// order, first match wins; `handler` is one of {"type":"script",...},
251    /// {"type":"static","actions":[...]} or {"type":"llm","instruction":"..."}.
252    /// Use get_protocol_docs for the protocol's client event ids and action names.
253    #[serde(default)]
254    pub event_handlers: Option<Vec<serde_json::Value>>,
255    /// Optional protocol-specific startup parameters (JSON object). Use
256    /// get_protocol_docs for a protocol's available startup parameters.
257    #[serde(default)]
258    pub startup_params: Option<serde_json::Value>,
259    /// Seed the client's LLM memory before the first event.
260    #[serde(default)]
261    pub initial_memory: Option<String>,
262    /// Natural-language instructions for the automatic feedback loop that adjusts
263    /// this client's behaviour as it runs.
264    #[serde(default)]
265    pub feedback_instructions: Option<String>,
266    /// Client-scoped scheduled LLM tasks. Same shape as start_server's:
267    /// {"task_id":"...", "recurring":true, "interval_secs":60, "instruction":"..."}
268    /// or {"task_id":"...", "recurring":false, "delay_secs":10, "instruction":"..."}.
269    #[serde(default)]
270    pub scheduled_tasks: Option<Vec<serde_json::Value>>,
271}
272
273#[derive(Debug, Deserialize, JsonSchema)]
274pub struct StopClientParams {
275    /// Client ID to stop
276    #[serde(deserialize_with = "deserialize_u32_flexible")]
277    pub client_id: u32,
278}
279
280#[derive(Debug, Deserialize, JsonSchema)]
281pub struct ClientStatusParams {
282    /// Client ID to query
283    #[serde(deserialize_with = "deserialize_u32_flexible")]
284    pub client_id: u32,
285}
286
287#[derive(Debug, Deserialize, JsonSchema)]
288pub struct StopServerParams {
289    /// Server ID to stop
290    #[serde(deserialize_with = "deserialize_u32_flexible")]
291    pub server_id: u32,
292}
293
294#[derive(Debug, Deserialize, JsonSchema)]
295pub struct ServerStatusParams {
296    /// Server ID to query
297    #[serde(deserialize_with = "deserialize_u32_flexible")]
298    pub server_id: u32,
299}
300
301#[derive(Debug, Deserialize, JsonSchema)]
302pub struct SetModelParams {
303    /// Model name (e.g., "qwen3-coder:30b", "llama3.2:latest")
304    pub model: String,
305}
306
307#[derive(Debug, Deserialize, JsonSchema)]
308pub struct GetProtocolDocsParams {
309    /// Protocol name (e.g., "http", "dns", "tcp")
310    pub protocol: String,
311}
312
313#[derive(Debug, Deserialize, JsonSchema)]
314pub struct ListAccessLogsParams {
315    /// Maximum number of recent entries to return (default 20)
316    #[serde(default, deserialize_with = "deserialize_option_u32_flexible")]
317    pub limit: Option<u32>,
318}
319
320#[derive(Debug, Deserialize, JsonSchema)]
321pub struct GetAccessLogParams {
322    /// Access-log entry id (from list_access_logs)
323    #[serde(deserialize_with = "deserialize_u64_flexible")]
324    pub id: u64,
325}
326
327#[derive(Debug, Deserialize, JsonSchema)]
328pub struct UpdateServerInstructionParams {
329    /// Server ID
330    #[serde(deserialize_with = "deserialize_u32_flexible")]
331    pub server_id: u32,
332    /// New instruction text
333    pub instruction: String,
334}
335
336#[derive(Debug, Deserialize, JsonSchema)]
337pub struct UpdateServerParams {
338    /// Server ID to update (from list_servers)
339    #[serde(deserialize_with = "deserialize_u32_flexible")]
340    pub server_id: u32,
341    /// New natural-language LLM instruction (hot-applied, connections preserved).
342    #[serde(default)]
343    pub instruction: Option<String>,
344    /// Replacement event handlers (hot-applied). Same shape as start_server's.
345    #[serde(default)]
346    pub event_handlers: Option<Vec<serde_json::Value>>,
347    /// Seed/replace the server's LLM memory (hot-applied).
348    #[serde(default)]
349    pub initial_memory: Option<String>,
350    /// Startup parameters to merge in. Validated against the protocol schema;
351    /// applying them triggers a clean stop+start (connections dropped, new id).
352    #[serde(default)]
353    pub startup_params: Option<serde_json::Value>,
354    /// New port to bind. Triggers a restart.
355    #[serde(default, deserialize_with = "deserialize_option_u16_flexible")]
356    pub port: Option<u16>,
357    /// New host/IP to bind. Triggers a restart.
358    #[serde(default)]
359    pub host: Option<String>,
360    /// New feedback-loop instructions (hot-applied).
361    #[serde(default)]
362    pub feedback_instructions: Option<String>,
363    /// Scheduled tasks to add (hot-applied). Same shape as start_server's.
364    #[serde(default)]
365    pub scheduled_tasks: Option<Vec<serde_json::Value>>,
366}
367
368#[derive(Debug, Deserialize, JsonSchema)]
369pub struct UpdateClientParams {
370    /// Client ID to update
371    #[serde(deserialize_with = "deserialize_u32_flexible")]
372    pub client_id: u32,
373    /// New natural-language LLM instruction (hot-applied).
374    #[serde(default)]
375    pub instruction: Option<String>,
376    /// Replacement event handlers (hot-applied).
377    #[serde(default)]
378    pub event_handlers: Option<Vec<serde_json::Value>>,
379    /// Seed/replace the client's LLM memory (hot-applied).
380    #[serde(default)]
381    pub initial_memory: Option<String>,
382    /// Startup parameters to merge in. Validated; triggers a reconnect (new id).
383    #[serde(default)]
384    pub startup_params: Option<serde_json::Value>,
385    /// New remote address "host:port". Triggers a reconnect.
386    #[serde(default)]
387    pub remote_addr: Option<String>,
388    /// New feedback-loop instructions (hot-applied).
389    #[serde(default)]
390    pub feedback_instructions: Option<String>,
391    /// Scheduled tasks to add (hot-applied).
392    #[serde(default)]
393    pub scheduled_tasks: Option<Vec<serde_json::Value>>,
394}
395
396#[derive(Debug, Deserialize, JsonSchema)]
397pub struct GetNextLlmRequestParams {
398    /// Seconds to long-poll for a request when none is immediately pending
399    /// (0 = return right away). Capped at 120 to stay under the client's tool-call timeout.
400    #[serde(default, deserialize_with = "deserialize_option_u32_flexible")]
401    pub wait_seconds: Option<u32>,
402}
403
404#[derive(Debug, Deserialize, JsonSchema)]
405pub struct AnswerLlmRequestParams {
406    /// The request id returned by get_next_llm_request
407    #[serde(deserialize_with = "deserialize_u64_flexible")]
408    pub request_id: u64,
409    /// NetGet actions to execute, as a JSON array. Each object has a `type` field
410    /// naming the action plus its parameters (same shape shown by get_access_log).
411    pub actions: Vec<serde_json::Value>,
412}
413
414#[derive(Debug, Deserialize, JsonSchema)]
415pub struct SendToClientParams {
416    /// Client ID (from list_clients)
417    #[serde(deserialize_with = "deserialize_u32_flexible")]
418    pub client_id: u32,
419    /// One of the client protocol's actions, as a JSON object with a `type` field naming it
420    /// plus its parameters, e.g. {"type":"send_tcp_data","data":"hello\n"}. get_protocol_docs
421    /// lists the actions a client accepts, with parameters and examples.
422    pub action: serde_json::Value,
423    /// Seconds to wait for the client's loop to execute the action and report (default 30,
424    /// max 120).
425    #[serde(default, deserialize_with = "deserialize_option_u32_flexible")]
426    pub timeout_secs: Option<u32>,
427}
428
429#[derive(Debug, Deserialize, JsonSchema)]
430pub struct SendToPeerParams {
431    /// Server ID (from list_servers)
432    #[serde(deserialize_with = "deserialize_u32_flexible")]
433    pub server_id: u32,
434    /// Connection ID of the live peer (server_status lists them, and marks which accept
435    /// send_to_peer)
436    #[serde(deserialize_with = "deserialize_u32_flexible")]
437    pub connection_id: u32,
438    /// One of the server protocol's actions, as a JSON object with a `type` field, e.g.
439    /// {"type":"send_tcp_data","data":"hello\n"}.
440    pub action: serde_json::Value,
441    /// Seconds to wait for the connection to execute the action and report (default 30,
442    /// max 120).
443    #[serde(default, deserialize_with = "deserialize_option_u32_flexible")]
444    pub timeout_secs: Option<u32>,
445}
446
447#[derive(Debug, Deserialize, JsonSchema)]
448pub struct DisconnectPeerParams {
449    /// Server ID (from list_servers)
450    #[serde(deserialize_with = "deserialize_u32_flexible")]
451    pub server_id: u32,
452    /// Connection ID of the live peer to hang up on (from server_status)
453    #[serde(deserialize_with = "deserialize_u32_flexible")]
454    pub connection_id: u32,
455}
456
457#[derive(Debug, Deserialize, JsonSchema)]
458pub struct AnswerInterceptParams {
459    /// The parked request's id (from list_intercepts)
460    #[serde(deserialize_with = "deserialize_u64_flexible")]
461    pub intercept_id: u64,
462    /// The answer: a JSON array of action objects, each {"type":"<name>", ...parameters},
463    /// using only the actions list_intercepts offers for this request. An EMPTY array is a
464    /// real answer — "acknowledge, say nothing": nothing is written and the connection
465    /// carries on. `{{event.<field>}}` references are filled from the request's event data,
466    /// exactly as in a static handler.
467    pub actions: Vec<serde_json::Value>,
468}
469
470#[derive(Debug, Deserialize, JsonSchema)]
471pub struct FailInterceptParams {
472    /// The parked request's id (from list_intercepts)
473    #[serde(deserialize_with = "deserialize_u64_flexible")]
474    pub intercept_id: u64,
475}
476
477/// Default wait for `send_to_client` / `send_to_peer`, the dashboard composer's own figure.
478const INJECT_TIMEOUT_DEFAULT_SECS: u32 = 30;
479/// Ceiling on a caller-supplied wait, under typical MCP client tool-call timeouts.
480const INJECT_TIMEOUT_MAX_SECS: u32 = 120;
481
482/// Ensure a named pipe (FIFO) exists at `path`, creating it if absent.
483#[cfg(unix)]
484fn ensure_fifo(path: &std::path::Path) -> anyhow::Result<()> {
485    use std::os::unix::ffi::OsStrExt;
486    use std::os::unix::fs::FileTypeExt;
487    match std::fs::symlink_metadata(path) {
488        Ok(metadata) if metadata.file_type().is_fifo() => return Ok(()),
489        Ok(_) => anyhow::bail!(
490            "--llm-agent-pipe path {} must be a FIFO, not a regular file, directory, or symlink",
491            path.display()
492        ),
493        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
494        Err(error) => return Err(error.into()),
495    }
496    let c_path = std::ffi::CString::new(path.as_os_str().as_bytes())
497        .map_err(|_| anyhow::anyhow!("invalid FIFO path: {}", path.display()))?;
498    // 0o600: owner read/write only.
499    let rc = unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) };
500    if rc != 0 {
501        return Err(std::io::Error::last_os_error())
502            .map_err(|e| anyhow::anyhow!("failed to create FIFO {}: {}", path.display(), e));
503    }
504    Ok(())
505}
506
507#[cfg(not(unix))]
508fn ensure_fifo(_path: &std::path::Path) -> anyhow::Result<()> {
509    anyhow::bail!("--llm-agent-pipe (named pipes) is only supported on Unix platforms")
510}
511
512/// How often the MCP-mode reaper runs. Matches the TUI's cleanup tick.
513const REAPER_INTERVAL_SECS: u64 = 5;
514/// How long a `Stopped` server stays visible before being reclaimed.
515const SERVER_CLEANUP_TIMEOUT_SECS: u64 = 30;
516/// How long a closed connection stays visible before being reclaimed.
517const CONNECTION_CLEANUP_TIMEOUT_SECS: u64 = 10;
518/// How long an idle connectionless (UDP-style) connection stays visible.
519const CONNECTIONLESS_CLEANUP_TIMEOUT_SECS: u64 = 10;
520
521/// Start the background reaper that reclaims stopped servers, closed connections
522/// and finished conversations in MCP mode.
523///
524/// The TUI drives this from its own event loop; MCP mode has no such loop, so
525/// without this task nothing ever called `cleanup_old_servers`. `close_server`
526/// (whether issued by the LLM or by the `stop_server` tool's TUI-equivalent path)
527/// only marks a server `Stopped`, so those entries — and, before the cleanup moved
528/// into `remove_server`, their scheduled tasks — accumulated for the life of the
529/// process.
530///
531/// It also drains due `provide_feedback` batches. `start_server` accepts
532/// `feedback_instructions`, which makes `provide_feedback` an advertised tool, so
533/// without this an MCP caller could switch the loop on and have every entry
534/// accumulate unread — the TUI and non-interactive modes drive the drain from their
535/// task timer. Scheduled tasks are driven separately by `spawn_task_ticker`.
536///
537/// SharedState owns this handle and aborts it when the final service goes away.
538fn spawn_state_reaper(
539    app_state: AppState,
540    llm_client: crate::llm::OllamaClient,
541    status_tx: mpsc::UnboundedSender<String>,
542) -> tokio::task::JoinHandle<()> {
543    tokio::spawn(async move {
544        let mut ticker =
545            tokio::time::interval(std::time::Duration::from_secs(REAPER_INTERVAL_SECS));
546        loop {
547            ticker.tick().await;
548            crate::llm::feedback::execute_due_feedback(&app_state, &llm_client, &status_tx).await;
549            app_state
550                .cleanup_old_servers(SERVER_CLEANUP_TIMEOUT_SECS)
551                .await;
552            app_state
553                .cleanup_closed_connections(CONNECTION_CLEANUP_TIMEOUT_SECS)
554                .await;
555            app_state
556                .cleanup_old_connections(CONNECTIONLESS_CLEANUP_TIMEOUT_SECS)
557                .await;
558            app_state.cleanup_old_conversations().await;
559        }
560    })
561}
562
563/// How often the MCP-mode scheduled-task ticker fires. Matches the 1s cadence the
564/// TUI event loop and the non-interactive `run_server` runner use for their task
565/// timers, so a task's declared `interval_secs`/`delay_secs` are honoured to the
566/// same resolution in every mode.
567const TASK_TICK_INTERVAL_SECS: u64 = 1;
568
569/// Start the background scheduled-task ticker for MCP mode.
570///
571/// The TUI event loop and the non-interactive runner both drive
572/// `execute_due_tasks_public` from a 1s timer; MCP mode had no such loop, so tasks
573/// created by `start_server`'s `scheduled_tasks` array or the `schedule_task` action
574/// sat at `Scheduled` forever and never fired. This restores that timer for both the
575/// STDIO and HTTP transports (they share this one `SharedState`).
576///
577/// Scoping is not re-implemented here: `execute_due_tasks_public` reads the live task
578/// set from `AppState` on every tick, so Global / Server / Connection scoping — and
579/// the removal of server- and connection-scoped tasks by `remove_server` on
580/// `stop_server`/`stop_all` — are honoured exactly as the TUI honours them. A stopped
581/// server's tasks are gone from state before the next tick, so they cannot fire; only
582/// Global tasks (which the TUI also keeps) persist.
583///
584/// SharedState owns this handle, so dropping the final service stops ticking.
585fn spawn_task_ticker(
586    app_state: AppState,
587    llm_client: crate::llm::OllamaClient,
588    status_tx: mpsc::UnboundedSender<String>,
589    owner: tokio_util::sync::CancellationToken,
590) -> tokio::task::JoinHandle<()> {
591    tokio::spawn(async move {
592        let mut ticker =
593            tokio::time::interval(std::time::Duration::from_secs(TASK_TICK_INTERVAL_SECS));
594        // A tick missed while a previous batch was executing should not stampede a
595        // burst of catch-up ticks, exactly as the TUI's task timer skips them.
596        ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
597        loop {
598            tokio::select! {
599                biased;
600                _ = owner.cancelled() => break,
601                _ = ticker.tick() => {}
602            }
603            crate::cli::execute_due_tasks_owned_public(
604                &app_state,
605                &llm_client,
606                &status_tx,
607                owner.clone(),
608            )
609            .await;
610        }
611    })
612}
613
614/// The port a server is actually listening on.
615///
616/// `ServerInstance::port` is the port that was *requested*, which is `0` for an OS-assigned
617/// one; the address it really bound is in `local_addr`. Reporting the requested value meant an
618/// MCP caller who started a server with `"port": 0` — the documented way to get an ephemeral
619/// port — was told it was listening on port 0 and had no way at all to discover the real one.
620/// `list_servers`, `get_server_details`, `get_status` and the `stop_server` confirmation all
621/// had it.
622///
623/// Same defect as the dashboard's edit form had (`ServerPrefill::from_server`), from the same
624/// cause: `port: 0` now passes through to the protocol's own bind, so every reader of `.port`
625/// has to resolve it.
626fn bound_port(server: &crate::state::server::ServerInstance) -> u16 {
627    server
628        .local_addr
629        .map(|a| a.port())
630        .filter(|p| *p != 0)
631        .unwrap_or(server.port)
632}
633
634// === Tool implementations ===
635
636#[tool_router]
637
638impl NetGetMcpService {
639    pub async fn new(args: &Args, settings: Settings) -> anyhow::Result<Self> {
640        let state = Self::create_shared_state(args, settings).await?;
641        Ok(Self::new_with_shared_state(state))
642    }
643
644    /// Create the shared state used by all MCP sessions.
645    ///
646    /// For STDIO this is called once for the single session; for the HTTP
647    /// transport it is created once and shared across all sessions so they
648    /// see the same servers/clients.
649    pub(crate) async fn create_shared_state(
650        args: &Args,
651        settings: Settings,
652    ) -> anyhow::Result<Arc<SharedState>> {
653        // Create app state.
654        //
655        // This is the same startup configuration the TUI (`cli::run`), the
656        // one-shot runner (`cli::non_interactive`) and `--client` all perform.
657        // MCP used to skip every bit of it — it built a bare `AppState::new()`,
658        // which hard-codes `RateLimiterConfig::default()`, `ScriptingMode::On`
659        // and `include_disabled_protocols: false`. So `--llm-max-concurrent`,
660        // `--llm-token-limit`, `--llm-token-window`, `--env`, `--no-scripts`,
661        // `--handler` and `--include-disabled-protocols` were all accepted and
662        // silently ignored in the mode this project documents as its primary
663        // programmatic surface. Anything added to the TUI's startup below
664        // belongs here too.
665        let base_url = args
666            .openai_url
667            .clone()
668            .or_else(|| args.ollama_url.clone())
669            .unwrap_or_else(|| "http://localhost:11434".to_string());
670        let app_state = AppState::new_with_options(args.include_disabled_protocols, base_url);
671        app_state
672            .set_min_stability(args.parse_min_stability()?)
673            .await;
674
675        app_state
676            .configure_rate_limiter(args.build_rate_limiter_config())
677            .await?;
678
679        // Scripting mode: CLI arg > saved setting. Unlike the TUI we do not
680        // validate availability by bailing, because there is no operator to read
681        // the message: an unavailable runtime is reported and the mode is left
682        // alone rather than killing the transport at handshake time.
683        if let Some(mode) = args
684            .parse_scripting_mode()?
685            .or_else(|| settings.parse_scripting_mode())
686        {
687            let scripting_env = app_state.get_scripting_env().await;
688            let available = match mode {
689                crate::state::app_state::ScriptingMode::On
690                | crate::state::app_state::ScriptingMode::Off => true,
691                crate::state::app_state::ScriptingMode::Python => scripting_env.python.is_some(),
692                crate::state::app_state::ScriptingMode::JavaScript => {
693                    scripting_env.javascript.is_some()
694                }
695                crate::state::app_state::ScriptingMode::Go => scripting_env.go.is_some(),
696                crate::state::app_state::ScriptingMode::Perl => scripting_env.perl.is_some(),
697            };
698            if available {
699                app_state.set_selected_scripting_mode(mode).await;
700            } else {
701                tracing::warn!(
702                    "Requested scripting environment {} is not available on this system; leaving scripting mode unchanged",
703                    mode
704                );
705            }
706        }
707
708        if let Some(handler_mode) = args.parse_event_handler_mode()? {
709            app_state.set_event_handler_mode(handler_mode).await;
710        }
711
712        // Web search: ASK needs a TUI to prompt with, and MCP has none, so it
713        // degrades to OFF exactly as the non-interactive runner does.
714        let mut web_search_mode = settings.get_web_search_mode();
715        if web_search_mode == crate::state::app_state::WebSearchMode::Ask {
716            web_search_mode = crate::state::app_state::WebSearchMode::Off;
717        }
718        app_state.set_web_search_mode(web_search_mode).await;
719
720        // Build the LLM client. Three mutually-exclusive sources:
721        //   --llm-agent      → queue requests for the calling MCP agent (no model)
722        //   --openai-url     → OpenAI-compatible endpoint
723        //   (default)        → local Ollama
724        let (llm_client, agent_queue) = if args.llm_agent {
725            let pipe = args.llm_agent_pipe.clone();
726            if let Some(ref path) = pipe {
727                ensure_fifo(path)?;
728            }
729            let queue = Arc::new(LlmRequestQueue::new(pipe));
730            let timeout = std::time::Duration::from_secs(args.llm_agent_timeout);
731            let client = OllamaClient::new_queue(queue.clone(), timeout);
732
733            // Pre-seed a placeholder model so `ensure_model_selected` never reaches
734            // out to Ollama's /api/tags (no real model is involved in this mode).
735            if args.model.is_none() {
736                app_state.set_ollama_model(Some("agent".to_string())).await;
737            }
738            (client, Some(queue))
739        } else {
740            // `with_app_state` is what records token usage against AppState (the
741            // `/usage` accounting); `with_mock_config_file` is what lets a test
742            // drive this transport without a model. Both were missing here while
743            // every other startup path set them.
744            let client = crate::cli::create_llm_client(args)?
745                .with_mock_config_file(args.mock_config_file.clone())
746                .with_app_state(app_state.clone());
747            (client, None)
748        };
749
750        // Status channel (messages go to stderr in MCP mode)
751        let (status_tx, mut status_rx) = mpsc::unbounded_channel();
752
753        // Set up model if specified: CLI arg > saved setting, matching every
754        // other startup path (MCP used to read only the CLI arg, so a model
755        // chosen in the TUI and persisted to settings was ignored here).
756        // (`--llm-agent` is excluded from the fallback: it pre-seeded the
757        // placeholder "agent" model above and contacts no model at all.)
758        let configured_model = args.model.clone().or_else(|| {
759            if args.llm_agent {
760                None
761            } else {
762                settings.model.clone()
763            }
764        });
765        if let Some(model) = configured_model {
766            app_state.set_ollama_model(Some(model)).await;
767        }
768
769        // Reap stopped servers, closed connections and finished conversations.
770        // Without this, MCP mode never reclaims them: `cleanup_old_servers` was only
771        // ticked by the TUI event loop, so an LLM-initiated `close_server` (which
772        // marks the server Stopped rather than removing it) left the entry — and its
773        // scheduled tasks — in AppState for the life of the process.
774        let reaper = spawn_state_reaper(app_state.clone(), llm_client.clone(), status_tx.clone());
775
776        // Fire scheduled tasks. Without this, `start_server`'s `scheduled_tasks` and
777        // the `schedule_task` action create tasks that never execute in MCP mode —
778        // only the TUI and non-interactive runners ticked `execute_due_tasks`.
779        let task_owner = tokio_util::sync::CancellationToken::new();
780        let ticker = spawn_task_ticker(
781            app_state.clone(),
782            llm_client.clone(),
783            status_tx.clone(),
784            task_owner.clone(),
785        );
786
787        // The receiver belongs to the same service lifetime as its producers.
788        let status_drain = tokio::spawn(async move {
789            while let Some(msg) = status_rx.recv().await {
790                eprintln!("[NETGET] {}", msg);
791            }
792        });
793        let state = Arc::new(SharedState {
794            app_state,
795            llm_client,
796            agent_queue,
797            _status_tx: status_tx,
798            background_tasks: vec![reaper, status_drain],
799            task_owner,
800            _task_ticker: ticker,
801        });
802
803        Ok(state)
804    }
805
806    /// Create a service instance backed by pre-built shared state
807    /// (used by the HTTP transport, which creates one service per session)
808    pub(crate) fn new_with_shared_state(state: Arc<SharedState>) -> Self {
809        Self {
810            state,
811            tool_router: Self::tool_router(),
812        }
813    }
814
815    #[tool(
816        description = "List all available network protocols that can be started as servers or clients"
817    )]
818    async fn list_protocols(
819        &self,
820        Parameters(params): Parameters<ListProtocolsParams>,
821    ) -> Result<CallToolResult, McpError> {
822        let filter_type = params.r#type.as_deref().unwrap_or("all");
823        let include_disabled = params.include_disabled.unwrap_or(false);
824        let mut result = String::new();
825
826        if filter_type == "all" || filter_type == "server" {
827            let registry = crate::protocol::server_registry::registry();
828            let protocols = registry.available_protocols();
829            result.push_str("## Server Protocols\n\n");
830            for name in &protocols {
831                if let Some(proto) = registry.get(name) {
832                    let metadata = proto.metadata();
833                    if !include_disabled
834                        && matches!(
835                            metadata.state,
836                            crate::protocol::metadata::DevelopmentState::Incomplete
837                        )
838                    {
839                        continue;
840                    }
841                    result.push_str(&format!(
842                        "- **{}** ({}) - {}\n",
843                        name,
844                        metadata.state.as_str(),
845                        metadata.implementation
846                    ));
847                }
848            }
849            result.push('\n');
850        }
851
852        if filter_type == "all" || filter_type == "client" {
853            let registry = &crate::protocol::CLIENT_REGISTRY;
854            let mut protocols = registry.list_protocols();
855            protocols.sort();
856            result.push_str("## Client Protocols\n\n");
857            result.push_str("Start one with `start_client` (needs a `remote_addr`).\n\n");
858            for name in &protocols {
859                if let Some(proto) = registry.get(name) {
860                    let metadata = proto.metadata();
861                    if !include_disabled
862                        && matches!(
863                            metadata.state,
864                            crate::protocol::metadata::DevelopmentState::Incomplete
865                        )
866                    {
867                        continue;
868                    }
869                    result.push_str(&format!(
870                        "- **{}** ({}) - {}\n",
871                        name,
872                        metadata.state.as_str(),
873                        proto.description()
874                    ));
875                } else {
876                    result.push_str(&format!("- **{}**\n", name));
877                }
878            }
879            result.push('\n');
880        }
881
882        Ok(CallToolResult::success(vec![Content::text(result)]))
883    }
884
885    #[tool(
886        description = "Start a network protocol server. Choose HOW it responds: for DETERMINISTIC behavior (echo, fixed/canned responses, simple routing, high throughput) pass `event_handlers` with a script or static handler — these run in-process with NO LLM call (instant, free, reproducible). Only use the natural-language `instruction` when responses genuinely need reasoning or vary unpredictably; it invokes NetGet's LLM on every request. Prefer event_handlers whenever the logic is fixed."
887    )]
888    async fn start_server(
889        &self,
890        Parameters(params): Parameters<StartServerParams>,
891    ) -> Result<CallToolResult, McpError> {
892        let instruction = params.instruction.unwrap_or_else(|| {
893            format!(
894                "You are a {} server. Handle requests appropriately.",
895                params.protocol
896            )
897        });
898
899        // Drive the new server with NetGet's configured LLM client (Ollama/OpenAI from CLI args)
900        self.state
901            .app_state
902            .set_llm_client(self.state.llm_client.clone())
903            .await;
904
905        info!(
906            "MCP: Starting {} server on {}:{}",
907            params.protocol,
908            params.host.as_deref().unwrap_or("127.0.0.1"),
909            params.port.unwrap_or(0)
910        );
911
912        let scheduled_tasks = match parse_scheduled_tasks(params.scheduled_tasks) {
913            Ok(tasks) => tasks,
914            Err(e) => {
915                return Ok(CallToolResult::error(vec![Content::text(format!(
916                    "Invalid scheduled_tasks: {}",
917                    e
918                ))]));
919            }
920        };
921
922        let (status_tx, mut status_rx) = mpsc::unbounded_channel();
923        let server_id = match crate::cli::server_startup::start_server_from_action(
924            &self.state.app_state,
925            params.mac_address.clone(),         // mac_address
926            params.interface.clone(),           // interface
927            params.host.clone(),                // host
928            params.port,                        // port
929            &params.protocol,                   // protocol
930            params.send_first.unwrap_or(false), // send_first
931            params.initial_memory,              // initial_memory
932            instruction,                        // instruction
933            params.startup_params,              // startup_params
934            params.event_handlers,              // event_handlers
935            scheduled_tasks,                    // scheduled_tasks
936            params.feedback_instructions,       // feedback_instructions
937            status_tx,                          // status_tx
938        )
939        .await
940        {
941            Ok(id) => id,
942            Err(e) => {
943                return Ok(CallToolResult::error(vec![Content::text(format!(
944                    "Failed to start server: {}",
945                    e
946                ))]));
947            }
948        };
949
950        // Collect startup status messages
951        let mut messages = Vec::new();
952        while let Ok(msg) = status_rx.try_recv() {
953            messages.push(msg);
954        }
955
956        let result = format!(
957            "Server #{} ({}) started.\n{}",
958            server_id.as_u32(),
959            params.protocol,
960            messages.join("\n")
961        );
962
963        Ok(CallToolResult::success(vec![Content::text(result)]))
964    }
965
966    #[tool(description = "Stop a running server by its ID")]
967    async fn stop_server(
968        &self,
969        Parameters(params): Parameters<StopServerParams>,
970    ) -> Result<CallToolResult, McpError> {
971        let server_id = crate::state::ServerId::new(params.server_id);
972
973        match self.state.app_state.get_server(server_id).await {
974            Some(server) => {
975                let protocol = server.protocol_name.clone();
976                let port = bound_port(&server);
977                self.state.app_state.remove_server(server_id).await;
978                Ok(CallToolResult::success(vec![Content::text(format!(
979                    "Server #{} ({} on port {}) stopped",
980                    params.server_id, protocol, port
981                ))]))
982            }
983            None => Ok(CallToolResult::error(vec![Content::text(format!(
984                "Server #{} not found",
985                params.server_id
986            ))])),
987        }
988    }
989
990    #[tool(
991        description = "List all running servers with their status, protocol, port, and connection count"
992    )]
993    async fn list_servers(&self) -> Result<CallToolResult, McpError> {
994        let servers = self.state.app_state.get_all_servers().await;
995
996        if servers.is_empty() {
997            return Ok(CallToolResult::success(vec![Content::text(
998                "No servers currently running.",
999            )]));
1000        }
1001
1002        let mut result = String::from("## Running Servers\n\n");
1003        for server in &servers {
1004            result.push_str(&format!(
1005                "- **Server #{}**: {} on port {} ({})\n  Instruction: {}\n  Memory: {}\n\n",
1006                server.id.as_u32(),
1007                server.protocol_name,
1008                bound_port(server),
1009                server.status,
1010                if server.instruction.is_empty() {
1011                    "(none)"
1012                } else {
1013                    &server.instruction
1014                },
1015                if server.memory.is_empty() {
1016                    "(empty)"
1017                } else {
1018                    &server.memory
1019                },
1020            ));
1021        }
1022
1023        Ok(CallToolResult::success(vec![Content::text(result)]))
1024    }
1025
1026    #[tool(description = "Get detailed status of a specific server")]
1027    async fn server_status(
1028        &self,
1029        Parameters(params): Parameters<ServerStatusParams>,
1030    ) -> Result<CallToolResult, McpError> {
1031        let server_id = crate::state::ServerId::new(params.server_id);
1032
1033        match self.state.app_state.get_server(server_id).await {
1034            Some(server) => {
1035                let result = format!(
1036                    "## Server #{}\n\n\
1037                     - **Protocol**: {}\n\
1038                     - **Port**: {}\n\
1039                     - **Status**: {}\n\
1040                     - **Instruction**: {}\n\
1041                     - **Memory**: {}\n",
1042                    server.id.as_u32(),
1043                    server.protocol_name,
1044                    bound_port(&server),
1045                    server.status,
1046                    if server.instruction.is_empty() {
1047                        "(none)"
1048                    } else {
1049                        &server.instruction
1050                    },
1051                    if server.memory.is_empty() {
1052                        "(empty)"
1053                    } else {
1054                        &server.memory
1055                    },
1056                );
1057                let mut result = result;
1058
1059                // Connections, with the id send_to_peer / disconnect_peer take and whether
1060                // each accepts them (only protocols that register a peer handle do).
1061                let mut connections: Vec<_> = server.connections.values().collect();
1062                connections.sort_by_key(|c| c.id.as_u32());
1063                if connections.is_empty() {
1064                    result.push_str("- **Connections**: none\n");
1065                } else {
1066                    result.push_str("\n### Connections\n\n");
1067                    if let Some(reason) = crate::protocol::server_registry::registry()
1068                        .request_only_reason(&server.protocol_name)
1069                    {
1070                        result.push_str(&format!(
1071                            "No connection accepts send_to_peer: {}.\n\n",
1072                            reason
1073                        ));
1074                    }
1075                    for conn in connections {
1076                        let injectable = self
1077                            .state
1078                            .app_state
1079                            .has_peer_handle(server_id, conn.id.as_u32())
1080                            .await;
1081                        result.push_str(&format!(
1082                            "- connection_id **{}** — {} ({:?}), {} byte(s) in / {} out{}\n",
1083                            conn.id.as_u32(),
1084                            conn.remote_addr,
1085                            conn.status,
1086                            conn.bytes_received,
1087                            conn.bytes_sent,
1088                            if injectable {
1089                                " — accepts send_to_peer / disconnect_peer"
1090                            } else {
1091                                ""
1092                            }
1093                        ));
1094                    }
1095                }
1096                Ok(CallToolResult::success(vec![Content::text(result)]))
1097            }
1098            None => Ok(CallToolResult::error(vec![Content::text(format!(
1099                "Server #{} not found",
1100                params.server_id
1101            ))])),
1102        }
1103    }
1104
1105    #[tool(
1106        description = "Get overall NetGet status including the configured model and running servers"
1107    )]
1108    async fn get_status(&self) -> Result<CallToolResult, McpError> {
1109        let servers = self.state.app_state.get_all_servers().await;
1110        let model = self
1111            .state
1112            .app_state
1113            .get_ollama_model()
1114            .await
1115            .unwrap_or_else(|| "(auto-select)".to_string());
1116
1117        let mut result = format!(
1118            "## NetGet Status\n\n\
1119             - **Model**: {}\n\
1120             - **LLM backend**: {}\n\
1121             - **Running servers**: {}\n",
1122            model,
1123            self.state.llm_client.backend_type(),
1124            servers.len(),
1125        );
1126
1127        if !servers.is_empty() {
1128            result.push_str("\n### Servers\n");
1129            for server in &servers {
1130                result.push_str(&format!(
1131                    "- #{}: {} on port {} ({})\n",
1132                    server.id.as_u32(),
1133                    server.protocol_name,
1134                    bound_port(server),
1135                    server.status
1136                ));
1137            }
1138        }
1139
1140        Ok(CallToolResult::success(vec![Content::text(result)]))
1141    }
1142
1143    #[tool(description = "Change the default LLM model used for new servers/clients")]
1144    async fn set_model(
1145        &self,
1146        Parameters(params): Parameters<SetModelParams>,
1147    ) -> Result<CallToolResult, McpError> {
1148        self.state
1149            .app_state
1150            .set_ollama_model(Some(params.model.clone()))
1151            .await;
1152        Ok(CallToolResult::success(vec![Content::text(format!(
1153            "Model changed to: {}",
1154            params.model
1155        ))]))
1156    }
1157
1158    #[tool(
1159        description = "Get MCP-shaped documentation for a protocol: the exact start_server/start_client arguments that apply, the protocol's event ids with their field names and types (needed to write an event_handlers script), its action names with parameter schemas and examples, its startup parameters, its privilege requirement, and its maturity."
1160    )]
1161    async fn get_protocol_docs(
1162        &self,
1163        Parameters(params): Parameters<GetProtocolDocsParams>,
1164    ) -> Result<CallToolResult, McpError> {
1165        match crate::mcp_stdio::docs::render_protocol_docs(&params.protocol, &self.state.app_state)
1166            .await
1167        {
1168            Some(text) => Ok(CallToolResult::success(vec![Content::text(text)])),
1169            None => {
1170                // Protocol not compiled in - list what's available
1171                let server_registry = crate::protocol::server_registry::registry();
1172                let available = server_registry.available_protocols();
1173                Ok(CallToolResult::error(vec![Content::text(format!(
1174                    "Protocol '{}' not found. It may not be compiled into this build.\n\n\
1175                     Available server protocols ({}):\n{}\n\n\
1176                     Build with the protocol feature enabled: cargo build --features mcp-stdio,{}",
1177                    params.protocol,
1178                    available.len(),
1179                    available
1180                        .iter()
1181                        .map(|p| format!("  - {}", p))
1182                        .collect::<Vec<_>>()
1183                        .join("\n"),
1184                    params.protocol.to_lowercase(),
1185                ))]))
1186            }
1187        }
1188    }
1189
1190    #[tool(description = "Update the LLM instruction for a running server")]
1191    async fn update_server_instruction(
1192        &self,
1193        Parameters(params): Parameters<UpdateServerInstructionParams>,
1194    ) -> Result<CallToolResult, McpError> {
1195        let server_id = crate::state::ServerId::new(params.server_id);
1196
1197        let updated = self
1198            .state
1199            .app_state
1200            .with_server_mut(server_id, |server| {
1201                server.instruction = params.instruction.clone();
1202            })
1203            .await;
1204
1205        match updated {
1206            Some(()) => Ok(CallToolResult::success(vec![Content::text(format!(
1207                "Server #{} instruction updated",
1208                params.server_id
1209            ))])),
1210            None => Ok(CallToolResult::error(vec![Content::text(format!(
1211                "Server #{} not found",
1212                params.server_id
1213            ))])),
1214        }
1215    }
1216
1217    #[tool(
1218        description = "Update a RUNNING server in place, by its id, instead of starting a second one. Supply only the fields to change. instruction / event_handlers / initial_memory / feedback_instructions / scheduled_tasks are hot-applied WITHOUT dropping connections; port / host / startup_params require a clean stop+start (connections dropped, server gets a NEW id). Unknown id and invalid startup_params error cleanly and leave the running server untouched. Prefer this over start_server when a server already exists."
1219    )]
1220    async fn update_server(
1221        &self,
1222        Parameters(params): Parameters<UpdateServerParams>,
1223    ) -> Result<CallToolResult, McpError> {
1224        // Keep AppState's LLM client in sync in case the update restarts the server.
1225        self.state
1226            .app_state
1227            .set_llm_client(self.state.llm_client.clone())
1228            .await;
1229
1230        let scheduled_tasks = match parse_scheduled_tasks(params.scheduled_tasks) {
1231            Ok(t) => t,
1232            Err(e) => {
1233                return Ok(CallToolResult::error(vec![Content::text(format!(
1234                    "Invalid scheduled_tasks: {}",
1235                    e
1236                ))]))
1237            }
1238        };
1239
1240        let form = crate::cli::management::ServerForm {
1241            protocol: String::new(),
1242            port: params.port,
1243            host: params.host,
1244            interface: None,
1245            mac_address: None,
1246            send_first: false,
1247            instruction: params.instruction,
1248            initial_memory: params.initial_memory,
1249            startup_params: params.startup_params,
1250            event_handlers: params.event_handlers,
1251            scheduled_tasks,
1252            feedback_instructions: params.feedback_instructions,
1253        };
1254
1255        let (status_tx, _status_rx) = mpsc::unbounded_channel();
1256        match crate::cli::management::update_server(
1257            &self.state.app_state,
1258            crate::state::ServerId::new(params.server_id),
1259            form,
1260            status_tx,
1261        )
1262        .await
1263        {
1264            Ok(outcome) => Ok(CallToolResult::success(vec![Content::text(
1265                outcome.summary,
1266            )])),
1267            Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])),
1268        }
1269    }
1270
1271    #[tool(
1272        description = "Update a RUNNING client in place, by its id, instead of connecting a second one. Supply only the fields to change. instruction / event_handlers / initial_memory / feedback_instructions / scheduled_tasks are hot-applied; remote_addr / startup_params trigger a reconnect (new id). Unknown id and invalid startup_params error cleanly and leave the running client untouched."
1273    )]
1274    async fn update_client(
1275        &self,
1276        Parameters(params): Parameters<UpdateClientParams>,
1277    ) -> Result<CallToolResult, McpError> {
1278        self.state
1279            .app_state
1280            .set_llm_client(self.state.llm_client.clone())
1281            .await;
1282
1283        let scheduled_tasks = match parse_scheduled_tasks(params.scheduled_tasks) {
1284            Ok(t) => t,
1285            Err(e) => {
1286                return Ok(CallToolResult::error(vec![Content::text(format!(
1287                    "Invalid scheduled_tasks: {}",
1288                    e
1289                ))]))
1290            }
1291        };
1292
1293        let form = crate::cli::management::ClientForm {
1294            protocol: String::new(),
1295            remote_addr: params.remote_addr,
1296            instruction: params.instruction,
1297            initial_memory: params.initial_memory,
1298            startup_params: params.startup_params,
1299            event_handlers: params.event_handlers,
1300            scheduled_tasks,
1301            feedback_instructions: params.feedback_instructions,
1302        };
1303
1304        let (status_tx, _status_rx) = mpsc::unbounded_channel();
1305        match crate::cli::management::update_client(
1306            &self.state.app_state,
1307            crate::state::ClientId::new(params.client_id),
1308            form,
1309            self.state.llm_client.clone(),
1310            status_tx,
1311        )
1312        .await
1313        {
1314            Ok(outcome) => Ok(CallToolResult::success(vec![Content::text(
1315                outcome.summary,
1316            )])),
1317            Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])),
1318        }
1319    }
1320
1321    #[tool(
1322        description = "List recent request/response access-log entries across all servers (newest first). Use get_access_log with an entry id to see the full request and response."
1323    )]
1324    async fn list_access_logs(
1325        &self,
1326        Parameters(params): Parameters<ListAccessLogsParams>,
1327    ) -> Result<CallToolResult, McpError> {
1328        let limit = params.limit.unwrap_or(20) as usize;
1329        let entries = self.state.app_state.list_access_logs(Some(limit)).await;
1330
1331        if entries.is_empty() {
1332            return Ok(CallToolResult::success(vec![Content::text(
1333                "No requests logged yet. Access logs are recorded as servers handle requests.",
1334            )]));
1335        }
1336
1337        let now_ms = crate::utils::clock::SystemTime::now()
1338            .duration_since(crate::utils::clock::UNIX_EPOCH)
1339            .map(|d| d.as_millis() as u64)
1340            .unwrap_or(0);
1341
1342        let mut out = format!("## Recent requests ({} shown)\n\n", entries.len());
1343        for e in &entries {
1344            let age = now_ms.saturating_sub(e.unix_ms) / 1000;
1345            let owner = match (e.server_id, e.client_id) {
1346                (Some(id), _) => format!("server #{id}"),
1347                (None, Some(id)) => format!("client #{id}"),
1348                (None, None) => "unknown".to_string(),
1349            };
1350            out.push_str(&format!(
1351                "- **#{}** [{}s ago] {} ({}) — {} — {} → {}\n",
1352                e.id,
1353                age,
1354                owner,
1355                e.protocol,
1356                e.event_type,
1357                summarize_request(&e.request),
1358                summarize_response(&e.response),
1359            ));
1360        }
1361        out.push_str("\nUse `get_access_log` with an id for the full request and response.");
1362
1363        Ok(CallToolResult::success(vec![Content::text(out)]))
1364    }
1365
1366    #[tool(
1367        description = "Get the full request and response for a single access-log entry by id (from list_access_logs)"
1368    )]
1369    async fn get_access_log(
1370        &self,
1371        Parameters(params): Parameters<GetAccessLogParams>,
1372    ) -> Result<CallToolResult, McpError> {
1373        match self.state.app_state.get_access_log(params.id).await {
1374            Some(e) => {
1375                let now_ms = crate::utils::clock::SystemTime::now()
1376                    .duration_since(crate::utils::clock::UNIX_EPOCH)
1377                    .map(|d| d.as_millis() as u64)
1378                    .unwrap_or(0);
1379                let age = now_ms.saturating_sub(e.unix_ms) / 1000;
1380                let request = serde_json::to_string_pretty(&e.request)
1381                    .unwrap_or_else(|_| e.request.to_string());
1382                let response = serde_json::to_string_pretty(&e.response)
1383                    .unwrap_or_else(|_| "[]".to_string());
1384                let owner = match (e.server_id, e.client_id) {
1385                    (Some(id), _) => format!("Server #{id}"),
1386                    (None, Some(id)) => format!("Client #{id}"),
1387                    (None, None) => "Unknown".to_string(),
1388                };
1389                let result = format!(
1390                    "## Access log #{}\n\n\
1391                     - **Owner**: {} ({})\n\
1392                     - **Connection**: {}\n\
1393                     - **Event**: {}\n\
1394                     - **When**: {}s ago (unix_ms {})\n\n\
1395                     ### Request\n```json\n{}\n```\n\n\
1396                     ### Response (actions)\n```json\n{}\n```\n",
1397                    e.id,
1398                    owner,
1399                    e.protocol,
1400                    e.connection_id
1401                        .map(|c| c.to_string())
1402                        .unwrap_or_else(|| "(connectionless)".to_string()),
1403                    e.event_type,
1404                    age,
1405                    e.unix_ms,
1406                    request,
1407                    response,
1408                );
1409                Ok(CallToolResult::success(vec![Content::text(result)]))
1410            }
1411            None => Ok(CallToolResult::error(vec![Content::text(format!(
1412                "No access-log entry #{} (it may have aged out of the buffer). Use list_access_logs to see current entries.",
1413                params.id
1414            ))])),
1415        }
1416    }
1417
1418    #[tool(description = "Stop all running servers and connections")]
1419    async fn stop_all(&self) -> Result<CallToolResult, McpError> {
1420        let server_ids = self.state.app_state.get_all_server_ids().await;
1421        let count = server_ids.len();
1422
1423        for id in server_ids {
1424            self.state.app_state.remove_server(id).await;
1425        }
1426
1427        Ok(CallToolResult::success(vec![Content::text(format!(
1428            "Stopped {} server(s)",
1429            count
1430        ))]))
1431    }
1432
1433    #[tool(
1434        description = "Connect a network protocol client to a remote server. The mirror of start_server: choose HOW it responds the same way — for DETERMINISTIC behavior pass `event_handlers` with a script or static handler (in-process, NO LLM call, instant and reproducible); only use the natural-language `instruction` when the decision genuinely needs reasoning, since it invokes NetGet's LLM on every event. Use get_protocol_docs for the protocol's client event ids, action names and startup parameters."
1435    )]
1436    async fn start_client(
1437        &self,
1438        Parameters(params): Parameters<StartClientParams>,
1439    ) -> Result<CallToolResult, McpError> {
1440        let instruction = params.instruction.unwrap_or_else(|| {
1441            format!(
1442                "You are a {} client connected to {}. Handle responses appropriately.",
1443                params.protocol, params.remote_addr
1444            )
1445        });
1446
1447        let scheduled_tasks = match parse_scheduled_tasks(params.scheduled_tasks) {
1448            Ok(tasks) => tasks,
1449            Err(e) => {
1450                return Ok(CallToolResult::error(vec![Content::text(format!(
1451                    "Invalid scheduled_tasks: {}",
1452                    e
1453                ))]));
1454            }
1455        };
1456
1457        // Drive the new client with NetGet's configured LLM client, exactly as
1458        // start_server does (the client half reads it from the argument rather
1459        // than from AppState, but keep AppState in sync for anything that does).
1460        self.state
1461            .app_state
1462            .set_llm_client(self.state.llm_client.clone())
1463            .await;
1464
1465        info!(
1466            "MCP: Connecting {} client to {}",
1467            params.protocol, params.remote_addr
1468        );
1469
1470        let client_id = match crate::cli::client_startup::start_client_from_action(
1471            &self.state.app_state,
1472            &params.protocol,
1473            &params.remote_addr,
1474            instruction,
1475            params.startup_params,
1476            params.initial_memory,
1477            params.event_handlers,
1478            scheduled_tasks,
1479            params.feedback_instructions,
1480            self.state.llm_client.clone(),
1481            None, // status_tx: MCP has no status stream, drain to tracing
1482        )
1483        .await
1484        {
1485            Ok(id) => id,
1486            Err(e) => {
1487                return Ok(CallToolResult::error(vec![Content::text(format!(
1488                    "Failed to start client: {}",
1489                    e
1490                ))]));
1491            }
1492        };
1493
1494        Ok(CallToolResult::success(vec![Content::text(format!(
1495            "Client #{} ({}) connected to {}.",
1496            client_id.as_u32(),
1497            params.protocol,
1498            params.remote_addr
1499        ))]))
1500    }
1501
1502    #[tool(
1503        description = "Stop a client by its ID. Aborts every background task the client owns (read loop, transport driver, in-flight requests), which releases its socket and stops any further LLM calls, then drops it from NetGet's state so it disappears from list_clients and client_status. Note that a handful of client protocols do not yet register their tasks; for those this still only drops the bookkeeping — see src/mcp_stdio/CLAUDE.md for the current list."
1504    )]
1505    async fn stop_client(
1506        &self,
1507        Parameters(params): Parameters<StopClientParams>,
1508    ) -> Result<CallToolResult, McpError> {
1509        let client_id = crate::state::ClientId::new(params.client_id);
1510
1511        match self.state.app_state.remove_client(client_id).await {
1512            Some(client) => Ok(CallToolResult::success(vec![Content::text(format!(
1513                "Client #{} ({} -> {}) stopped: its registered background tasks were \
1514                 aborted and it was removed from NetGet's state.",
1515                params.client_id, client.protocol_name, client.remote_addr
1516            ))])),
1517            None => Ok(CallToolResult::error(vec![Content::text(format!(
1518                "Client #{} not found",
1519                params.client_id
1520            ))])),
1521        }
1522    }
1523
1524    #[tool(
1525        description = "List all clients with their protocol, remote address, status and instruction"
1526    )]
1527    async fn list_clients(&self) -> Result<CallToolResult, McpError> {
1528        let clients = self.state.app_state.get_all_clients().await;
1529
1530        if clients.is_empty() {
1531            return Ok(CallToolResult::success(vec![Content::text(
1532                "No clients currently connected.",
1533            )]));
1534        }
1535
1536        let mut clients = clients;
1537        clients.sort_by_key(|c| c.id.as_u32());
1538
1539        let mut result = String::from("## Clients\n\n");
1540        for client in &clients {
1541            result.push_str(&format!(
1542                "- **Client #{}**: {} -> {} ({})\n  Instruction: {}\n  Memory: {}\n\n",
1543                client.id.as_u32(),
1544                client.protocol_name,
1545                client.remote_addr,
1546                client.status,
1547                if client.instruction.is_empty() {
1548                    "(none)"
1549                } else {
1550                    &client.instruction
1551                },
1552                if client.memory.is_empty() {
1553                    "(empty)"
1554                } else {
1555                    &client.memory
1556                },
1557            ));
1558        }
1559
1560        Ok(CallToolResult::success(vec![Content::text(result)]))
1561    }
1562
1563    #[tool(description = "Get detailed status of a specific client")]
1564    async fn client_status(
1565        &self,
1566        Parameters(params): Parameters<ClientStatusParams>,
1567    ) -> Result<CallToolResult, McpError> {
1568        let client_id = crate::state::ClientId::new(params.client_id);
1569
1570        match self.state.app_state.get_client(client_id).await {
1571            Some(client) => {
1572                let mut result = format!(
1573                    "## Client #{}\n\n\
1574                     - **Protocol**: {}\n\
1575                     - **Remote address**: {}\n\
1576                     - **Status**: {}\n\
1577                     - **Instruction**: {}\n\
1578                     - **Memory**: {}\n",
1579                    client.id.as_u32(),
1580                    client.protocol_name,
1581                    client.remote_addr,
1582                    client.status,
1583                    if client.instruction.is_empty() {
1584                        "(none)"
1585                    } else {
1586                        &client.instruction
1587                    },
1588                    if client.memory.is_empty() {
1589                        "(empty)"
1590                    } else {
1591                        &client.memory
1592                    },
1593                );
1594
1595                if let Some(conn) = &client.connection {
1596                    result.push_str(&format!(
1597                        "- **Local address**: {}\n\
1598                         - **Connected address**: {}\n\
1599                         - **Bytes sent/received**: {}/{}\n\
1600                         - **Packets sent/received**: {}/{}\n",
1601                        conn.local_addr
1602                            .map(|a| a.to_string())
1603                            .unwrap_or_else(|| "(unknown)".to_string()),
1604                        conn.connected_addr
1605                            .map(|a| a.to_string())
1606                            .unwrap_or_else(|| "(unknown)".to_string()),
1607                        conn.bytes_sent,
1608                        conn.bytes_received,
1609                        conn.packets_sent,
1610                        conn.packets_received,
1611                    ));
1612                }
1613
1614                Ok(CallToolResult::success(vec![Content::text(result)]))
1615            }
1616            None => Ok(CallToolResult::error(vec![Content::text(format!(
1617                "Client #{} not found",
1618                params.client_id
1619            ))])),
1620        }
1621    }
1622
1623    #[tool(
1624        description = "Put one action on the wire through a RUNNING client, now, without waiting for an event — the dashboard's [ send ]. `action` is one of the client protocol's actions as a JSON object ({\"type\":\"send_tcp_data\",\"data\":\"hello\\n\"}); its `type` is checked against the client's action set first (get_protocol_docs lists them with parameters and examples). The client's own connection loop executes it and the result says what happened: bytes sent, executed without writing, rejected by the protocol, or disconnected. Clients whose protocol has not adopted the command channel return an error immediately rather than hanging."
1625    )]
1626    async fn send_to_client(
1627        &self,
1628        Parameters(params): Parameters<SendToClientParams>,
1629    ) -> Result<CallToolResult, McpError> {
1630        let client_id = crate::state::ClientId::new(params.client_id);
1631        let Some(client) = self.state.app_state.get_client(client_id).await else {
1632            return Ok(CallToolResult::error(vec![Content::text(format!(
1633                "Client #{} not found",
1634                params.client_id
1635            ))]));
1636        };
1637
1638        let vocabulary = match crate::mcp_stdio::control::client_vocabulary(
1639            &self.state.app_state,
1640            &client.protocol_name,
1641        ) {
1642            Ok(v) => v,
1643            Err(e) => return Ok(CallToolResult::error(vec![Content::text(e)])),
1644        };
1645        if let Err(e) = crate::mcp_stdio::control::check_action_types(
1646            std::slice::from_ref(&params.action),
1647            &vocabulary,
1648            &[],
1649        ) {
1650            return Ok(CallToolResult::error(vec![Content::text(format!(
1651                "Client #{} ({}): {}",
1652                params.client_id, client.protocol_name, e
1653            ))]));
1654        }
1655
1656        // Fail fast rather than waiting out the timeout on a client that has no channel.
1657        if !self.state.app_state.has_client_handle(client_id).await {
1658            return Ok(CallToolResult::error(vec![Content::text(format!(
1659                "Client #{} ({}, {}) does not accept injected actions: it is not connected, or \
1660                 its protocol has not adopted the command channel. Its actions can only be \
1661                 produced by its instruction or event_handlers.",
1662                params.client_id, client.protocol_name, client.status
1663            ))]));
1664        }
1665
1666        let timeout = std::time::Duration::from_secs(
1667            params
1668                .timeout_secs
1669                .unwrap_or(INJECT_TIMEOUT_DEFAULT_SECS)
1670                .clamp(1, INJECT_TIMEOUT_MAX_SECS) as u64,
1671        );
1672        match self
1673            .state
1674            .app_state
1675            .send_to_client(client_id, params.action, timeout)
1676            .await
1677        {
1678            Ok(crate::state::client_handles::ClientSendOutcome::Rejected { error }) => {
1679                Ok(CallToolResult::error(vec![Content::text(format!(
1680                    "Client #{}: the protocol rejected the action: {}",
1681                    params.client_id, error
1682                ))]))
1683            }
1684            Ok(outcome) => Ok(CallToolResult::success(vec![Content::text(format!(
1685                "Client #{}: {}",
1686                params.client_id,
1687                crate::mcp_stdio::control::describe_outcome(&outcome)
1688            ))])),
1689            Err(e) => {
1690                // A client parks its events for a manual handler inside its own loop, so
1691                // while one of its requests waits in list_intercepts nothing drains the
1692                // command channel. Say so, or the failure reads as a dead client.
1693                let waiting = self
1694                    .state
1695                    .app_state
1696                    .list_intercepts()
1697                    .await
1698                    .into_iter()
1699                    .filter(|v| {
1700                        v.owner == crate::state::intercepts::InterceptOwner::Client(client_id)
1701                    })
1702                    .map(|v| format!("#{}", v.id))
1703                    .collect::<Vec<_>>();
1704                let hint = if waiting.is_empty() {
1705                    String::new()
1706                } else {
1707                    format!(
1708                        " — its loop is parked on request {} waiting for an answer \
1709                         (list_intercepts / answer_intercept); it takes commands again once \
1710                         that is answered",
1711                        waiting.join(", ")
1712                    )
1713                };
1714                Ok(CallToolResult::error(vec![Content::text(format!(
1715                    "{e}{hint}"
1716                ))]))
1717            }
1718        }
1719    }
1720
1721    #[tool(
1722        description = "Put one action on the wire to ONE live peer of a running server, now, without waiting for the peer to speak — the dashboard's [ message ] on a peer row. `connection_id` comes from server_status, which also marks the connections that accept this (only protocols that register a peer handle do; tcp and telnet among them). `action` is one of the server protocol's actions ({\"type\":\"send_tcp_data\",\"data\":\"hi\\n\"}), checked against its action set first. Errors clearly when the protocol or connection has no peer handle, and with the protocol's own reason when it only answers requests (HTTP and the protocols on it)."
1723    )]
1724    async fn send_to_peer(
1725        &self,
1726        Parameters(params): Parameters<SendToPeerParams>,
1727    ) -> Result<CallToolResult, McpError> {
1728        let server_id = crate::state::ServerId::new(params.server_id);
1729        let Some(server) = self.state.app_state.get_server(server_id).await else {
1730            return Ok(CallToolResult::error(vec![Content::text(format!(
1731                "Server #{} not found",
1732                params.server_id
1733            ))]));
1734        };
1735        if let Some(reason) =
1736            crate::protocol::server_registry::registry().request_only_reason(&server.protocol_name)
1737        {
1738            return Ok(CallToolResult::error(vec![Content::text(format!(
1739                "Server #{} ({}) cannot message a peer: {}. It speaks only in answer to a \
1740                 request — answer those through its event handlers, the model, or a `manual` \
1741                 rule and answer_intercept.",
1742                params.server_id, server.protocol_name, reason
1743            ))]));
1744        }
1745        let vocabulary = match crate::mcp_stdio::control::peer_vocabulary(
1746            &self.state.app_state,
1747            &server.protocol_name,
1748        ) {
1749            Ok(v) => v,
1750            Err(e) => return Ok(CallToolResult::error(vec![Content::text(e)])),
1751        };
1752        if let Err(e) = crate::mcp_stdio::control::check_action_types(
1753            std::slice::from_ref(&params.action),
1754            &vocabulary,
1755            &[],
1756        ) {
1757            return Ok(CallToolResult::error(vec![Content::text(format!(
1758                "Server #{} ({}): {}",
1759                params.server_id, server.protocol_name, e
1760            ))]));
1761        }
1762        self.inject_into_peer(
1763            server_id,
1764            &server.protocol_name,
1765            params.connection_id,
1766            params.action,
1767            params.timeout_secs,
1768        )
1769        .await
1770    }
1771
1772    #[tool(
1773        description = "Hang up on ONE live peer of a running server from the server's side — the dashboard's [ disconnect ]. Runs the protocol's own close_connection through the connection's peer handle (half-close, and the connection is marked closed at once). Only for protocols that register a peer handle and declare close_connection; errors clearly otherwise."
1774    )]
1775    async fn disconnect_peer(
1776        &self,
1777        Parameters(params): Parameters<DisconnectPeerParams>,
1778    ) -> Result<CallToolResult, McpError> {
1779        let server_id = crate::state::ServerId::new(params.server_id);
1780        let Some(server) = self.state.app_state.get_server(server_id).await else {
1781            return Ok(CallToolResult::error(vec![Content::text(format!(
1782                "Server #{} not found",
1783                params.server_id
1784            ))]));
1785        };
1786        let declares_close = crate::mcp_stdio::control::peer_vocabulary(
1787            &self.state.app_state,
1788            &server.protocol_name,
1789        )
1790        .map(|v| v.iter().any(|a| a.name == "close_connection"))
1791        .unwrap_or(false);
1792        if !declares_close {
1793            return Ok(CallToolResult::error(vec![Content::text(format!(
1794                "Server #{} ({}) declares no close_connection action, so there is no \
1795                 server-side hang-up to run for one peer. stop_server closes every connection.",
1796                params.server_id, server.protocol_name
1797            ))]));
1798        }
1799        self.inject_into_peer(
1800            server_id,
1801            &server.protocol_name,
1802            params.connection_id,
1803            serde_json::json!({"type": "close_connection"}),
1804            None,
1805        )
1806        .await
1807    }
1808
1809    #[tool(
1810        description = "List every request parked for a human answer by a `manual` event handler ({\"event_pattern\":\"*\",\"handler\":{\"type\":\"manual\",\"timeout_secs\":300}}) — the dashboard's \"waiting for YOUR answer\" rows. For each: its id, the server or client and connection it came from, the event type and its data, the actions it may be answered with (name(params), * = required), and how many seconds remain before it fails closed on its own. Answer with answer_intercept, refuse with fail_intercept."
1811    )]
1812    async fn list_intercepts(&self) -> Result<CallToolResult, McpError> {
1813        let views = self.state.app_state.list_intercepts().await;
1814        if views.is_empty() {
1815            return Ok(CallToolResult::success(vec![Content::text(
1816                "No requests are waiting for an answer.",
1817            )]));
1818        }
1819        let now = crate::state::intercepts::now_unix_ms();
1820        let mut out = format!("## Requests waiting for an answer ({})\n\n", views.len());
1821        for view in &views {
1822            let (protocol, vocabulary) =
1823                match crate::mcp_stdio::control::intercept_vocabulary(&self.state.app_state, view)
1824                    .await
1825                {
1826                    Ok(pair) => pair,
1827                    Err(e) => (format!("unknown — {e}"), Vec::new()),
1828                };
1829            let owner = match view.owner {
1830                crate::state::intercepts::InterceptOwner::Server(id) => {
1831                    format!("server #{} ({})", id.as_u32(), protocol)
1832                }
1833                crate::state::intercepts::InterceptOwner::Client(id) => {
1834                    format!("client #{} ({})", id.as_u32(), protocol)
1835                }
1836            };
1837            let data = view
1838                .event_data
1839                .as_ref()
1840                .map(|d| serde_json::to_string(d).unwrap_or_else(|_| d.to_string()))
1841                .unwrap_or_else(|| "(none)".to_string());
1842            let answers: Vec<String> = vocabulary
1843                .iter()
1844                .map(crate::mcp_stdio::control::action_signature)
1845                .collect();
1846            out.push_str(&format!(
1847                "### intercept_id {}\n\
1848                 - **From**: {}{}\n\
1849                 - **Event**: `{}` — {}\n\
1850                 - **Event data**: `{}`\n\
1851                 - **Answer with**: {}\n\
1852                 - **Fails closed in**: {}s (of {}s)\n\n",
1853                view.id,
1854                owner,
1855                view.connection_id
1856                    .map(|c| format!(", connection #{c}"))
1857                    .unwrap_or_default(),
1858                view.event_type,
1859                view.description,
1860                data,
1861                if answers.is_empty() {
1862                    "(no protocol actions — answer with [] or fail it)".to_string()
1863                } else {
1864                    answers.join(", ")
1865                },
1866                view.seconds_until_fail_closed(now),
1867                view.timeout_secs,
1868            ));
1869        }
1870        out.push_str(
1871            "answer_intercept {intercept_id, actions:[…]} answers (an empty array = \
1872             acknowledge and say nothing); fail_intercept {intercept_id} refuses now, and the \
1873             peer gets the protocol's fail-closed reply. get_protocol_docs has each action's \
1874             parameters and an example.",
1875        );
1876        Ok(CallToolResult::success(vec![Content::text(out)]))
1877    }
1878
1879    #[tool(
1880        description = "Answer a parked request (from list_intercepts) — the dashboard's [ Compose answer… ] / [ Answer with nothing ]. `actions` is a JSON array of action objects using only the actions list_intercepts offers for it; they run exactly as a static handler's would (same executor, same {{event.field}} interpolation). An EMPTY array is a real answer: acknowledge and say nothing — nothing is written, the connection carries on — which is distinct from letting it time out (that fails closed). Errors if the request is no longer waiting (answered, refused, timed out, or its connection closed)."
1881    )]
1882    async fn answer_intercept(
1883        &self,
1884        Parameters(params): Parameters<AnswerInterceptParams>,
1885    ) -> Result<CallToolResult, McpError> {
1886        let Some(view) = self
1887            .state
1888            .app_state
1889            .list_intercepts()
1890            .await
1891            .into_iter()
1892            .find(|v| v.id == params.intercept_id)
1893        else {
1894            return Ok(CallToolResult::error(vec![Content::text(format!(
1895                "Request #{} is not waiting (already answered, refused, timed out, or its \
1896                 connection closed). list_intercepts shows what is.",
1897                params.intercept_id
1898            ))]));
1899        };
1900
1901        let (protocol, vocabulary) =
1902            match crate::mcp_stdio::control::intercept_vocabulary(&self.state.app_state, &view)
1903                .await
1904            {
1905                Ok(pair) => pair,
1906                Err(e) => return Ok(CallToolResult::error(vec![Content::text(e)])),
1907            };
1908        if let Err(e) = crate::mcp_stdio::control::check_action_types(
1909            &params.actions,
1910            &vocabulary,
1911            crate::events::handler::common_action_names(),
1912        ) {
1913            return Ok(CallToolResult::error(vec![Content::text(format!(
1914                "Request #{} ({} `{}`) was NOT answered and is still waiting: {}",
1915                params.intercept_id, protocol, view.event_type, e
1916            ))]));
1917        }
1918
1919        let names: Vec<String> = params
1920            .actions
1921            .iter()
1922            .filter_map(|a| a.get("type").and_then(|t| t.as_str()).map(str::to_string))
1923            .collect();
1924        match self
1925            .state
1926            .app_state
1927            .resolve_intercept(params.intercept_id, params.actions)
1928            .await
1929        {
1930            Ok(()) if names.is_empty() => {
1931                Ok(CallToolResult::success(vec![Content::text(format!(
1932                    "Answered request #{} with nothing: acknowledged, no reply written, the \
1933                     connection carries on.",
1934                    params.intercept_id
1935                ))]))
1936            }
1937            Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
1938                "Answered request #{} with {}. The waiting connection executes them now; \
1939                 list_access_logs shows the outcome.",
1940                params.intercept_id,
1941                names.join(", ")
1942            ))])),
1943            Err(e) => Ok(CallToolResult::error(vec![Content::text(e)])),
1944        }
1945    }
1946
1947    #[tool(
1948        description = "Refuse a parked request (from list_intercepts) now instead of letting it wait out its timeout — the dashboard's [ Fail closed ]. The peer gets exactly what it would get if the model had failed: the protocol's own fail-closed reply (for tcp, a half-close; for http, an error status), never an invented success. There is no separate 'dismiss': refusing a parked request IS failing it closed."
1949    )]
1950    async fn fail_intercept(
1951        &self,
1952        Parameters(params): Parameters<FailInterceptParams>,
1953    ) -> Result<CallToolResult, McpError> {
1954        if self
1955            .state
1956            .app_state
1957            .dismiss_intercept(params.intercept_id)
1958            .await
1959        {
1960            Ok(CallToolResult::success(vec![Content::text(format!(
1961                "Refused request #{}: the peer gets the protocol's fail-closed reply.",
1962                params.intercept_id
1963            ))]))
1964        } else {
1965            Ok(CallToolResult::error(vec![Content::text(format!(
1966                "Request #{} is not waiting (already answered, refused or timed out).",
1967                params.intercept_id
1968            ))]))
1969        }
1970    }
1971
1972    #[tool(
1973        description = "Agent-LLM mode only (netget --llm-agent): fetch the next queued LLM request for a protocol server so YOU (the calling agent) can answer it in place of a model. Optionally long-poll with wait_seconds. Returns the request id, the prompt (server instruction + the triggering event), and the actions you may use. Reply by calling answer_llm_request with that id and a JSON array of actions. Returns '(no pending requests)' if none arrive within the wait."
1974    )]
1975    async fn get_next_llm_request(
1976        &self,
1977        Parameters(params): Parameters<GetNextLlmRequestParams>,
1978    ) -> Result<CallToolResult, McpError> {
1979        let Some(queue) = self.state.agent_queue.as_ref() else {
1980            return Ok(CallToolResult::error(vec![Content::text(
1981                "Agent-LLM mode is not enabled. Start netget with --llm-agent to queue LLM requests for the agent.",
1982            )]));
1983        };
1984
1985        // Cap the long-poll to stay comfortably under typical MCP client tool-call timeouts.
1986        let wait = params.wait_seconds.unwrap_or(0).min(120);
1987        let req = queue
1988            .wait_and_claim(std::time::Duration::from_secs(wait as u64))
1989            .await;
1990
1991        let Some(req) = req else {
1992            return Ok(CallToolResult::success(vec![Content::text(
1993                "(no pending requests)",
1994            )]));
1995        };
1996
1997        // Render the prompt (system + user messages) and the available actions.
1998        let mut prompt = String::new();
1999        for m in &req.messages {
2000            prompt.push_str(&format!("### {}\n{}\n\n", m.role, m.content));
2001        }
2002
2003        let actions_json =
2004            serde_json::to_string_pretty(&req.tools).unwrap_or_else(|_| "[]".to_string());
2005
2006        let now_ms = crate::utils::clock::SystemTime::now()
2007            .duration_since(crate::utils::clock::UNIX_EPOCH)
2008            .map(|d| d.as_millis() as u64)
2009            .unwrap_or(0);
2010        let age = now_ms.saturating_sub(req.created_unix_ms) / 1000;
2011
2012        let result = format!(
2013            "## LLM request #{}\n\n\
2014             - **Queued**: {}s ago\n\n\
2015             ### Prompt\n{}\n\
2016             ### Available actions (tool schemas)\n```json\n{}\n```\n\n\
2017             To answer, call `answer_llm_request` with:\n\
2018             ```json\n{{ \"request_id\": {}, \"actions\": [ /* one or more action objects */ ] }}\n```\n\
2019             Each action object needs a `type` field naming the action, plus its parameters. \
2020             See `get_access_log` for worked examples of the action JSON a protocol expects.",
2021            req.id, age, prompt, actions_json, req.id,
2022        );
2023
2024        Ok(CallToolResult::success(vec![Content::text(result)]))
2025    }
2026
2027    #[tool(
2028        description = "Agent-LLM mode only: answer a queued LLM request (from get_next_llm_request). `actions` is a JSON array of NetGet action objects — each with a `type` field and its parameters — that the protocol server will execute (e.g. [{\"type\":\"send_tcp_data\",\"data\":\"...\"}]). This unblocks the waiting connection."
2029    )]
2030    async fn answer_llm_request(
2031        &self,
2032        Parameters(params): Parameters<AnswerLlmRequestParams>,
2033    ) -> Result<CallToolResult, McpError> {
2034        let Some(queue) = self.state.agent_queue.as_ref() else {
2035            return Ok(CallToolResult::error(vec![Content::text(
2036                "Agent-LLM mode is not enabled. Start netget with --llm-agent.",
2037            )]));
2038        };
2039
2040        match queue.answer(params.request_id, params.actions) {
2041            Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
2042                "Answered LLM request #{}.",
2043                params.request_id
2044            ))])),
2045            Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])),
2046        }
2047    }
2048
2049    #[tool(
2050        description = "Agent-LLM mode only: list outstanding queued LLM requests (pending and claimed-but-unanswered) for monitoring."
2051    )]
2052    async fn list_llm_requests(&self) -> Result<CallToolResult, McpError> {
2053        let Some(queue) = self.state.agent_queue.as_ref() else {
2054            return Ok(CallToolResult::error(vec![Content::text(
2055                "Agent-LLM mode is not enabled. Start netget with --llm-agent.",
2056            )]));
2057        };
2058
2059        let pending = queue.list();
2060        if pending.is_empty() {
2061            return Ok(CallToolResult::success(vec![Content::text(
2062                "No outstanding LLM requests.",
2063            )]));
2064        }
2065
2066        let now_ms = crate::utils::clock::SystemTime::now()
2067            .duration_since(crate::utils::clock::UNIX_EPOCH)
2068            .map(|d| d.as_millis() as u64)
2069            .unwrap_or(0);
2070
2071        let mut out = String::from("## Outstanding LLM requests\n\n");
2072        for r in &pending {
2073            let age = now_ms.saturating_sub(r.created_unix_ms) / 1000;
2074            out.push_str(&format!(
2075                "- **#{}** — {} — queued {}s ago\n",
2076                r.id,
2077                if r.claimed { "claimed" } else { "pending" },
2078                age
2079            ));
2080        }
2081        Ok(CallToolResult::success(vec![Content::text(out)]))
2082    }
2083}
2084
2085impl NetGetMcpService {
2086    /// Shared body of `send_to_peer` and `disconnect_peer`: check the connection has a peer
2087    /// handle (a clear error when the protocol or connection has none), then execute the
2088    /// action inside that connection's own task and report the outcome.
2089    async fn inject_into_peer(
2090        &self,
2091        server_id: crate::state::ServerId,
2092        protocol_name: &str,
2093        connection_id: u32,
2094        action: serde_json::Value,
2095        timeout_secs: Option<u32>,
2096    ) -> Result<CallToolResult, McpError> {
2097        if !self
2098            .state
2099            .app_state
2100            .has_peer_handle(server_id, connection_id)
2101            .await
2102        {
2103            return Ok(CallToolResult::error(vec![Content::text(format!(
2104                "Connection #{} on server #{} ({}) does not accept injected actions: either \
2105                 it is not a live connection of that server (server_status lists them), or \
2106                 the {} protocol does not register a peer handle for its connections.",
2107                connection_id,
2108                server_id.as_u32(),
2109                protocol_name,
2110                protocol_name
2111            ))]));
2112        }
2113        let timeout = std::time::Duration::from_secs(
2114            timeout_secs
2115                .unwrap_or(INJECT_TIMEOUT_DEFAULT_SECS)
2116                .clamp(1, INJECT_TIMEOUT_MAX_SECS) as u64,
2117        );
2118        match self
2119            .state
2120            .app_state
2121            .send_to_peer(server_id, connection_id, action, timeout)
2122            .await
2123        {
2124            Ok(crate::state::client_handles::ClientSendOutcome::Rejected { error }) => {
2125                Ok(CallToolResult::error(vec![Content::text(format!(
2126                    "Connection #{} on server #{}: the protocol rejected the action: {}",
2127                    connection_id,
2128                    server_id.as_u32(),
2129                    error
2130                ))]))
2131            }
2132            Ok(outcome) => Ok(CallToolResult::success(vec![Content::text(format!(
2133                "Connection #{} on server #{}: {}",
2134                connection_id,
2135                server_id.as_u32(),
2136                crate::mcp_stdio::control::describe_outcome(&outcome)
2137            ))])),
2138            Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])),
2139        }
2140    }
2141
2142    /// The `AppState` this service drives.
2143    ///
2144    /// `SharedState` is crate-private and the `#[tool]` methods are private to the
2145    /// router, so an integration test can only observe a tool's *text* result. That
2146    /// is not enough to assert teardown: `stop_server` reports "stopped" whether or
2147    /// not the server's scheduled tasks went with it. This accessor is what lets
2148    /// `tests/mcp_stop_cleanup_test.rs` check the state behind the tool surface.
2149    ///
2150    /// Handing out a clone is safe — `AppState` is an `Arc` over the shared lock, so
2151    /// this is the same state the tools mutate, not a snapshot.
2152    pub fn app_state(&self) -> AppState {
2153        self.state.app_state.clone()
2154    }
2155}
2156
2157#[tool_handler]
2158impl ServerHandler for NetGetMcpService {
2159    fn get_info(&self) -> <RoleServer as rmcp::service::ServiceRole>::Info {
2160        InitializeResult::new(ServerCapabilities::builder().enable_tools().build())
2161            .with_server_info(Implementation::new("netget", env!("CARGO_PKG_VERSION")))
2162            .with_instructions(
2163                "NetGet - LLM-controlled network protocol server. \
2164                 Use list_protocols to discover available protocols, then start_server to \
2165                 create one. \
2166                 IMPORTANT — choose the handling mode deliberately: for deterministic \
2167                 behavior (echo servers, fixed/canned responses, simple routing, high \
2168                 throughput) pass `event_handlers` with a script or static handler, which \
2169                 run in-process with NO LLM call (instant, free, reproducible). Reserve the \
2170                 natural-language `instruction` (the LLM fallback, one model call per \
2171                 request) for responses that genuinely need reasoning or vary \
2172                 unpredictably. Use get_protocol_docs to see a protocol's event ids and \
2173                 actions before writing a handler.\n\n\
2174                 CLIENTS: start_client / list_clients / client_status / stop_client are \
2175                 the mirror of the server tools, for connecting OUT to a remote server \
2176                 instead of listening. They take the same instruction and event_handlers \
2177                 arguments plus a remote_addr. stop_client aborts the client's background \
2178                 tasks, releasing its socket and stopping further LLM calls.\n\n\
2179                 DRIVING BY HAND: send_to_client puts one of a client's actions on the wire \
2180                 now; send_to_peer / disconnect_peer do the same for one live connection of a \
2181                 server (server_status lists connection ids). A `manual` event handler parks \
2182                 each matching event for YOU: list_intercepts shows them, answer_intercept \
2183                 answers (an empty array acknowledges without replying), fail_intercept \
2184                 refuses with the protocol's fail-closed reply.\n\n\
2185                 AGENT-LLM MODE (when netget was started with --llm-agent): there is no \
2186                 model — YOU answer the LLM calls. When a server needs a reasoned \
2187                 response it queues a request; fetch it with get_next_llm_request \
2188                 (optionally long-poll via wait_seconds), then reply with \
2189                 answer_llm_request(request_id, actions). If --llm-agent-pipe was set, a \
2190                 line with the new request id is written to that FIFO on each enqueue so \
2191                 you can block-read it instead of polling. Unanswered requests error out \
2192                 after the configured timeout. list_llm_requests shows what is outstanding.",
2193            )
2194    }
2195
2196    fn initialize(
2197        &self,
2198        request: InitializeRequestParams,
2199        context: RequestContext<RoleServer>,
2200    ) -> impl std::future::Future<Output = Result<InitializeResult, McpError>> + Send + '_ {
2201        async move {
2202            info!(
2203                "MCP client '{}' v{} initialized",
2204                request.client_info.name, request.client_info.version,
2205            );
2206
2207            // Retain peer info for the connection so rmcp can answer later requests
2208            if context.peer.peer_info().is_none() {
2209                context.peer.set_peer_info(request);
2210            }
2211
2212            Ok(self.get_info())
2213        }
2214    }
2215}
2216
2217/// Parse the MCP `scheduled_tasks` argument into typed task definitions.
2218///
2219/// Kept as raw JSON on the wire because `ServerTaskDefinition` does not derive
2220/// `JsonSchema`; a bad entry is reported to the caller rather than silently
2221/// dropped.
2222fn parse_scheduled_tasks(
2223    tasks: Option<Vec<serde_json::Value>>,
2224) -> Result<Option<Vec<crate::llm::actions::common::ServerTaskDefinition>>, String> {
2225    let Some(tasks) = tasks else {
2226        return Ok(None);
2227    };
2228    if tasks.is_empty() {
2229        return Ok(None);
2230    }
2231    let mut parsed = Vec::with_capacity(tasks.len());
2232    for (i, task) in tasks.into_iter().enumerate() {
2233        match serde_json::from_value::<crate::llm::actions::common::ServerTaskDefinition>(task) {
2234            Ok(def) => parsed.push(def),
2235            Err(e) => {
2236                return Err(format!(
2237                    "entry {}: {}. Each entry needs \"task_id\" (string), \"recurring\" (boolean) \
2238                     and \"instruction\" (string); recurring tasks also take \"interval_secs\", \
2239                     one-shot tasks \"delay_secs\".",
2240                    i, e
2241                ));
2242            }
2243        }
2244    }
2245    Ok(Some(parsed))
2246}
2247
2248/// Build a short one-line summary of a request (event data) for the log list.
2249fn summarize_request(request: &serde_json::Value) -> String {
2250    // HTTP-style requests: method + path
2251    let method = request.get("method").and_then(|v| v.as_str());
2252    let path = request.get("path").and_then(|v| v.as_str());
2253    if let (Some(m), Some(p)) = (method, path) {
2254        return format!("{} {}", m, p);
2255    }
2256    if let Some(p) = path {
2257        return p.to_string();
2258    }
2259    // Fall back to a compact, truncated form of the request JSON
2260    let compact = request.to_string();
2261    crate::utils::truncate_with_suffix(&compact, 79, "…")
2262}
2263
2264/// Build a short one-line summary of the response action array for the log list.
2265fn summarize_response(response: &[serde_json::Value]) -> String {
2266    if response.is_empty() {
2267        return "(no response)".to_string();
2268    }
2269    let parts: Vec<String> = response
2270        .iter()
2271        .map(|a| {
2272            let ty = a.get("type").and_then(|v| v.as_str()).unwrap_or("action");
2273            // Surface an HTTP status code if present
2274            match a
2275                .get("status_code")
2276                .or_else(|| a.get("status"))
2277                .and_then(|v| v.as_u64())
2278            {
2279                Some(code) => format!("{}({})", ty, code),
2280                None => ty.to_string(),
2281            }
2282        })
2283        .collect();
2284    parts.join(", ")
2285}