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}