Skip to main content

netscli_mcp/
server.rs

1mod dispatch;
2mod errors;
3#[cfg(feature = "pcap")]
4mod jobs;
5mod limits;
6mod operations;
7mod protocol;
8mod schemas;
9mod targets;
10mod tools;
11
12use std::sync::{Arc, Mutex};
13
14use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter};
15use tokio::sync::{mpsc, Semaphore};
16use tokio::task::JoinSet;
17
18use dispatch::{handle_request, ServerState};
19use protocol::{JsonRpcRequest, JsonRpcResponse};
20
21/// Ceiling on requests executing at once.
22///
23/// Each in-flight request can itself fan out to `Ops`-level concurrency
24/// (which clamps to 1024 sockets), so this bounds how many of those fans a
25/// misbehaving or enthusiastic client can stack up. Requests beyond the
26/// limit queue rather than being rejected — ordering of *responses* is not
27/// guaranteed by JSON-RPC, but every request still gets answered.
28const MAX_CONCURRENT_REQUESTS: usize = 16;
29
30/// Longest any single request may run before it is abandoned.
31///
32/// The per-probe timeouts are bounded, but nothing bounded probes multiplied
33/// by timeout: `ping_host` with `count=256` at the 10-minute per-probe
34/// ceiling is roughly 42 hours, and it holds one of the permits above for
35/// all of it. Sixteen such calls wedged the server with no way back.
36const MAX_REQUEST_DURATION: std::time::Duration = std::time::Duration::from_secs(15 * 60);
37
38/// Longest line accepted on stdin.
39///
40/// `next_line` grows a `String` without limit, so a client that sends
41/// megabytes and no newline was an unbounded allocation.
42const MAX_REQUEST_LINE_BYTES: usize = 4 * 1024 * 1024;
43
44pub use tools::tools_list;
45
46/// Initialize a tracing subscriber that writes JSON to stderr.
47///
48/// Uses `RUST_LOG` if set, otherwise defaults to `info`. Uses `try_init`
49/// so it's a no-op when the caller already installed a subscriber (e.g.
50/// a host binary that wants its own format).
51///
52/// **Why stderr, not stdout?** stdout is the JSON-RPC transport — any
53/// byte written there that isn't a valid response will break the client.
54fn init_tracing() {
55    use tracing_subscriber::{fmt, EnvFilter};
56
57    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
58
59    let _ = fmt()
60        .with_writer(std::io::stderr)
61        .with_env_filter(filter)
62        .with_target(false)
63        .json()
64        .with_current_span(false)
65        .with_span_list(false)
66        .try_init();
67}
68
69pub async fn run_server() -> anyhow::Result<()> {
70    init_tracing();
71    tracing::info!(
72        version = env!("CARGO_PKG_VERSION"),
73        pcap = cfg!(feature = "pcap"),
74        "netscli MCP server starting"
75    );
76
77    let stdin = io::stdin();
78    let stdout = io::stdout();
79    // `.take()` bounds a single line; `lines()` on its own grows without
80    // limit. The reader is rebuilt per line below so the cap applies to each.
81    let mut reader = BufReader::new(stdin);
82    let state = Arc::new(Mutex::new(ServerState::default()));
83    let limiter = Arc::new(Semaphore::new(MAX_CONCURRENT_REQUESTS));
84
85    // Responses are funnelled through one channel to a single writer task.
86    // Two concurrent handlers must never interleave bytes on stdout, and a
87    // dedicated writer is simpler to reason about than sharing a locked
88    // writer between tasks.
89    let (tx, mut rx) = mpsc::unbounded_channel::<String>();
90    let writer_task = tokio::spawn(async move {
91        let mut writer = BufWriter::new(stdout);
92        let mut written: u64 = 0;
93        while let Some(line) = rx.recv().await {
94            if writer.write_all(line.as_bytes()).await.is_err()
95                || writer.write_all(b"\n").await.is_err()
96                || writer.flush().await.is_err()
97            {
98                // stdout is gone (client detached); nothing useful is left
99                // to do on this transport.
100                break;
101            }
102            written += 1;
103        }
104        written
105    });
106
107    // Serialising here rather than in each handler keeps the error path off
108    // the spawned tasks, which have no way to propagate a `?`.
109    let send = |tx: &mpsc::UnboundedSender<String>, response: &JsonRpcResponse| {
110        match serde_json::to_string(response) {
111            Ok(s) => {
112                let _ = tx.send(s);
113            }
114            Err(e) => tracing::error!(error = %e, "failed to serialize response"),
115        }
116    };
117
118    // Handles are held so shutdown can abort them. Discarding them meant a
119    // client disconnect cancelled nothing: the writer below waited on every
120    // in-flight scan, which for a long capture is an hour.
121    let mut handlers: JoinSet<()> = JoinSet::new();
122
123    loop {
124        let mut line = String::new();
125        match read_bounded_line(&mut reader, &mut line).await {
126            Ok(0) => break,
127            Ok(_) => {}
128            // Invalid UTF-8 used to end the whole server through `?`,
129            // dropping every in-flight scan and answering nothing -- while
130            // malformed *JSON* was handled gracefully two lines below. One
131            // stray byte is a bad message, not a reason to exit.
132            Err(LineError::Invalid(reason)) => {
133                tracing::warn!(reason, "unreadable line on stdin");
134                send(&tx, &JsonRpcResponse::parse_error());
135                continue;
136            }
137            Err(LineError::Fatal(e)) => return Err(e.into()),
138        }
139        let line = line.trim_end().to_string();
140        if line.trim().is_empty() {
141            continue;
142        }
143
144        // Reap finished handlers so the set does not grow for the life of
145        // the process.
146        while handlers.try_join_next().is_some() {}
147
148        let request = match serde_json::from_str::<JsonRpcRequest>(&line) {
149            Ok(req) => req,
150            Err(e) => {
151                tracing::warn!(error = %e, "json parse error");
152                send(&tx, &JsonRpcResponse::parse_error());
153                continue;
154            }
155        };
156
157        // A *missing* id makes this a notification, which must not get a
158        // response. An explicit `"id": null` is a request and is handled
159        // below like any other.
160        if request.id.is_none() {
161            tracing::debug!(method = %request.method, "notification");
162            if request.method == "notifications/initialized" {
163                if let Ok(mut guard) = state.lock() {
164                    guard.initialized = true;
165                }
166            }
167            continue;
168        }
169
170        // The permit is taken *here*, before spawning, so the semaphore
171        // bounds admission and not merely execution. Acquiring it inside the
172        // task meant a client could stack up unbounded tasks, each holding a
173        // full `params` value, faster than they completed; backpressure now
174        // reaches stdin, which is where it can actually slow the client down.
175        //
176        // Reading the next line still does not wait on the handler, so a slow
177        // `sweep_network` does not block requests queued behind it.
178        let Ok(permit) = Arc::clone(&limiter).acquire_owned().await else {
179            break;
180        };
181
182        let state = Arc::clone(&state);
183        let tx = tx.clone();
184        // `Option<Option<_>>` distinguishes absent from explicit null; a
185        // notification never reaches here, so the outer layer is always Some.
186        let id = request.id.clone().flatten();
187        handlers.spawn(async move {
188            let _permit = permit;
189            // A hard ceiling on the whole call. Per-probe timeouts are
190            // bounded but their product was not, and a request that never
191            // returns holds its permit forever.
192            let response =
193                match tokio::time::timeout(MAX_REQUEST_DURATION, handle_request(state, request))
194                    .await
195                {
196                    Ok(response) => response,
197                    Err(_) => {
198                        tracing::warn!(
199                            timeout_s = MAX_REQUEST_DURATION.as_secs(),
200                            "request exceeded the maximum duration"
201                        );
202                        JsonRpcResponse::request_timeout(id, MAX_REQUEST_DURATION.as_secs())
203                    }
204                };
205            match serde_json::to_string(&response) {
206                Ok(s) => {
207                    let _ = tx.send(s);
208                }
209                Err(e) => tracing::error!(error = %e, "failed to serialize response"),
210            }
211        });
212    }
213
214    // EOF: the client is gone, so nothing is waiting for these answers.
215    // Aborting is what makes disconnect actually cancel -- the writer used
216    // to block until the longest in-flight scan finished, which for an
217    // hour-long capture meant an hour.
218    let aborted = handlers.len();
219    handlers.shutdown().await;
220    drop(tx);
221    let requests_handled = writer_task.await.unwrap_or(0);
222
223    tracing::info!(
224        requests_handled,
225        aborted,
226        "netscli MCP server shutting down"
227    );
228    Ok(())
229}
230
231/// Why a line could not be read.
232enum LineError {
233    /// The bytes were not a usable line, but the transport is still fine.
234    Invalid(&'static str),
235    /// The transport itself failed.
236    Fatal(std::io::Error),
237}
238
239/// Read one line, bounded, treating undecodable bytes as a bad message
240/// rather than the end of the server.
241async fn read_bounded_line<R>(reader: &mut R, out: &mut String) -> Result<usize, LineError>
242where
243    R: tokio::io::AsyncBufRead + Unpin,
244{
245    let mut raw = Vec::new();
246    // One byte past the limit, so an over-long line is detected rather than
247    // silently truncated into something that might still parse as JSON.
248    let mut limited = tokio::io::AsyncReadExt::take(reader, MAX_REQUEST_LINE_BYTES as u64 + 1);
249    let read = limited
250        .read_until(b'\n', &mut raw)
251        .await
252        .map_err(LineError::Fatal)?;
253    if read == 0 {
254        return Ok(0);
255    }
256    if raw.len() > MAX_REQUEST_LINE_BYTES {
257        return Err(LineError::Invalid("line exceeds the maximum request size"));
258    }
259    match String::from_utf8(raw) {
260        Ok(text) => {
261            out.push_str(&text);
262            Ok(read)
263        }
264        Err(_) => Err(LineError::Invalid("line is not valid UTF-8")),
265    }
266}