protocol_droid/adapters/secondary/stdio/spec.rs
1//! The child program, as typed values rather than a command line.
2//!
3//! # Why a value and not a `Command`
4//!
5//! Everything that names a child arrives as text: an argv from a config file, a
6//! string from a CLI flag, a pair of `KEY=VALUE` lines from an environment
7//! file. None of that is a process, and the difference matters before the
8//! process exists. A program name may be empty, an environment key may contain
9//! the `=` that separates it from its value, and neither may contain a NUL —
10//! three failures that are all silent at the point the text is accepted and all
11//! fatal at the point the spawn is attempted.
12//!
13//! [`ChildSpec`] is where those refusals happen. It is built by constructors
14//! that return [`ChildSpecError`], so a caller holding a `ChildSpec` is
15//! holding something the operating system will accept, and the spawn in
16//! [`child`](super::child) can report only *one* failure: the process did not
17//! start.
18//!
19//! # `OsString`, not `String`
20//!
21//! An argument is a sequence of bytes to the operating system, not text. A
22//! Windows path is UTF-16, a Unix path need not be UTF-8 at all, and a
23//! round trip through `String` fails on both. So the typed value here is
24//! [`OsString`], and the only check applied is the one the operating system
25//! itself applies: no NUL, because a NUL terminates every C string on the way
26//! in and there is no escaping it past that.
27//!
28//! # Inheriting the environment, deliberately
29//!
30//! A spawned MCP server is usually a command a user wrote for their own
31//! machine, and it usually needs `PATH` to be worth anything. So the
32//! environment is inherited unless a caller says otherwise with
33//! [`without_inherited_env`](ChildSpec::without_inherited_env), and the
34//! entries in [`env`](ChildSpec::env) are additions to that rather than a
35//! replacement for it. Clearing it and re-adding only what the spec names is
36//! the other mode, and it is a mode, not a default: a server that cannot find
37//! its own `node` is a bug report nobody can act on.
38
39use std::ffi::{OsStr, OsString};
40use std::path::{Path, PathBuf};
41
42use tokio::process::Command;
43
44use thiserror::Error;
45
46/// A child MCP server's program, arguments, and environment.
47///
48/// Built by [`new`](ChildSpec::new) or parsed from an argv with
49/// [`from_argv`](ChildSpec::from_argv), both of which refuse a spec the
50/// operating system would refuse. See the module docs for why.
51///
52/// # Examples
53///
54/// ```
55/// use protocol_droid::adapters::secondary::stdio::ChildSpec;
56///
57/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
58/// let spec = ChildSpec::new("npx")?
59/// .with_args(["-y", "@modelcontextprotocol/server-filesystem", "/tmp"])?
60/// .with_env("RUST_LOG", "warn")?
61/// .with_current_dir("/srv");
62///
63/// assert_eq!(spec.program(), std::ffi::OsStr::new("npx"));
64/// assert_eq!(spec.args().len(), 3);
65/// # Ok(())
66/// # }
67/// ```
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct ChildSpec {
70 program: OsString,
71 args: Vec<OsString>,
72 env: Vec<(OsString, OsString)>,
73 current_dir: Option<PathBuf>,
74 inherit_env: bool,
75}
76
77impl ChildSpec {
78 /// A program with no arguments and an inherited environment.
79 ///
80 /// # Errors
81 ///
82 /// [`ChildSpecError::EmptyProgram`] when the program is empty, and
83 /// [`ChildSpecError::Unrepresentable`] when it contains a NUL.
84 pub fn new(program: impl Into<OsString>) -> Result<Self, ChildSpecError> {
85 let program = program.into();
86 if program.is_empty() {
87 return Err(ChildSpecError::EmptyProgram);
88 }
89 reject_nul("the program name", &program)?;
90 Ok(Self {
91 program,
92 args: Vec::new(),
93 env: Vec::new(),
94 current_dir: None,
95 inherit_env: true,
96 })
97 }
98
99 /// A program read from an argv, where the first element is the program.
100 ///
101 /// The convention every platform already uses — including `std::env::args`,
102 /// which this is shaped for — so a caller holding a parsed command line
103 /// hands it over whole rather than splitting it here and getting the split
104 /// wrong.
105 ///
106 /// # Errors
107 ///
108 /// As [`ChildSpec::new`], applied to the first element, and as
109 /// [`with_args`](ChildSpec::with_args), applied to the rest. An empty argv
110 /// has no program to refuse, so it is [`ChildSpecError::EmptyArgv`].
111 ///
112 /// # Examples
113 ///
114 /// ```
115 /// use protocol_droid::adapters::secondary::stdio::ChildSpec;
116 ///
117 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
118 /// let spec = ChildSpec::from_argv(["my-server", "--stdio"])?;
119 ///
120 /// assert_eq!(spec.program(), std::ffi::OsStr::new("my-server"));
121 /// assert_eq!(spec.args(), [std::ffi::OsString::from("--stdio")]);
122 /// # Ok(())
123 /// # }
124 /// ```
125 pub fn from_argv<I, S>(argv: I) -> Result<Self, ChildSpecError>
126 where
127 I: IntoIterator<Item = S>,
128 S: Into<OsString>,
129 {
130 let mut argv = argv.into_iter().map(Into::into);
131 let Some(program) = argv.next() else {
132 return Err(ChildSpecError::EmptyArgv);
133 };
134 Self::new(program)?.with_args(argv)
135 }
136
137 /// Appends one argument.
138 ///
139 /// The single-argument form of [`with_args`](ChildSpec::with_args), for the
140 /// call site that has one.
141 ///
142 /// # Errors
143 ///
144 /// [`ChildSpecError::Unrepresentable`] when the argument contains a NUL.
145 pub fn with_arg(mut self, arg: impl Into<OsString>) -> Result<Self, ChildSpecError> {
146 let arg = arg.into();
147 reject_nul("an argument", &arg)?;
148 self.args.push(arg);
149 Ok(self)
150 }
151
152 /// Appends several arguments.
153 ///
154 /// # Errors
155 ///
156 /// [`ChildSpecError::Unrepresentable`] when any argument contains a NUL.
157 /// Nothing is appended in that case, so a caller that ignores the error
158 /// gets the spec it started with rather than one that is silently short at
159 /// the spawn.
160 pub fn with_args<I, S>(mut self, args: I) -> Result<Self, ChildSpecError>
161 where
162 I: IntoIterator<Item = S>,
163 S: Into<OsString>,
164 {
165 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
166 for arg in &args {
167 reject_nul("an argument", arg)?;
168 }
169 self.args.extend(args);
170 Ok(self)
171 }
172
173 /// Adds an environment variable for the child.
174 ///
175 /// An addition, not a replacement: see the module docs on inherited
176 /// environments. A key already present is replaced, because that is what
177 /// the second assignment to the same name means.
178 ///
179 /// # Errors
180 ///
181 /// [`ChildSpecError::EmptyEnvKey`] when the key is empty or contains the
182 /// `=` that separates a key from its value, and
183 /// [`ChildSpecError::Unrepresentable`] when either side contains a NUL.
184 ///
185 /// No `#[must_use]`: the return type is a `Result`, which is already
186 /// marked, and saying it twice is noise rather than emphasis.
187 pub fn with_env(
188 mut self,
189 key: impl Into<OsString>,
190 value: impl Into<OsString>,
191 ) -> Result<Self, ChildSpecError> {
192 let key = key.into();
193 let value = value.into();
194 if key.is_empty() {
195 return Err(ChildSpecError::EmptyEnvKey);
196 }
197 if key.as_encoded_bytes().contains(&b'=') {
198 return Err(ChildSpecError::EnvKeyContainsSeparator);
199 }
200 reject_nul("an environment key", &key)?;
201 reject_nul("an environment value", &value)?;
202 self.env.retain(|(existing, _)| existing != &key);
203 self.env.push((key, value));
204 Ok(self)
205 }
206
207 /// Runs the child in `directory`.
208 #[must_use]
209 pub fn with_current_dir(mut self, directory: impl Into<PathBuf>) -> Self {
210 self.current_dir = Some(directory.into());
211 self
212 }
213
214 /// Starts the child with no inherited environment.
215 ///
216 /// Everything outside [`env`](ChildSpec::env) is then absent — including
217 /// `PATH` on Unix, which usually means the program cannot be found. That is
218 /// a real cost and the reason this is not the default; it is the right
219 /// choice when a server is being started with a deliberately minimal
220 /// environment, and the wrong one everywhere else.
221 #[must_use]
222 pub const fn without_inherited_env(mut self) -> Self {
223 self.inherit_env = false;
224 self
225 }
226
227 /// The program, as the operating system will be asked to find it.
228 #[must_use]
229 pub fn program(&self) -> &OsStr {
230 &self.program
231 }
232
233 /// The arguments, excluding the program.
234 #[must_use]
235 pub fn args(&self) -> &[OsString] {
236 &self.args
237 }
238
239 /// The environment entries added to whatever is inherited.
240 #[must_use]
241 pub fn env(&self) -> &[(OsString, OsString)] {
242 &self.env
243 }
244
245 /// The working directory, when one was set.
246 #[must_use]
247 pub fn current_dir(&self) -> Option<&Path> {
248 self.current_dir.as_deref()
249 }
250
251 /// Whether the child starts from this process's environment.
252 #[must_use]
253 pub const fn inherits_env(&self) -> bool {
254 self.inherit_env
255 }
256
257 /// The [`Command`] this spec describes.
258 ///
259 /// Public because a caller embedding this transport may want to add
260 /// something the spec has no field for — a process group, a uid — and
261 /// there is no reason for it to have to rebuild the spec to do it. The
262 /// pipes and the kill-on-drop flag are *not* set here; they belong to
263 /// `child::spawn`, which is the only thing in the
264 /// crate that may claim to have set them.
265 #[must_use]
266 pub fn to_command(&self) -> Command {
267 let mut command = Command::new(&self.program);
268 command.args(&self.args);
269 if !self.inherit_env {
270 command.env_clear();
271 }
272 for (key, value) in &self.env {
273 command.env(key, value);
274 }
275 if let Some(directory) = &self.current_dir {
276 command.current_dir(directory);
277 }
278 command
279 }
280}
281
282/// Refuses a value containing a NUL, which no process can be given.
283///
284/// A NUL terminates every C string on the way into the kernel, on every
285/// platform, so `spawn` would silently truncate the argument at it. Refusing
286/// here is the difference between an error naming the field and a server
287/// started with the wrong path.
288fn reject_nul(what: &'static str, value: &OsStr) -> Result<(), ChildSpecError> {
289 if value.as_encoded_bytes().contains(&0) {
290 return Err(ChildSpecError::Unrepresentable { what });
291 }
292 Ok(())
293}
294
295/// Why a set of process arguments is not a program the operating system can run.
296#[derive(Debug, Clone, PartialEq, Eq, Error)]
297pub enum ChildSpecError {
298 /// The argv had no elements, so there was no program to run.
299 #[error("the argv is empty, so there is no program to run")]
300 EmptyArgv,
301
302 /// The program name was empty.
303 #[error("the program name is empty")]
304 EmptyProgram,
305
306 /// An environment key was empty.
307 #[error("an environment key is empty")]
308 EmptyEnvKey,
309
310 /// An environment key contained `=`, which is the separator a key may not
311 /// contain.
312 #[error("an environment key contains '=', which separates a key from its value")]
313 EnvKeyContainsSeparator,
314
315 /// A value contained a NUL, which terminates the string before the kernel
316 /// ever sees it.
317 #[error("{what} contains a NUL, which no process can be given")]
318 Unrepresentable {
319 /// Which of the spec's fields was rejected.
320 what: &'static str,
321 },
322}
323
324#[cfg(test)]
325mod tests {
326 use std::ffi::OsString;
327
328 use assert_matches::assert_matches;
329
330 use super::{ChildSpec, ChildSpecError};
331
332 /// What a `?` in a test here can produce. Both are the type under test, so
333 /// there is nothing to collect and no reason to unwrap.
334 type Failure = ChildSpecError;
335
336 /// The program is the first thing a spec has, and a spec without one is
337 /// not a program at all.
338 #[test]
339 fn a_program_becomes_a_spec() -> Result<(), Failure> {
340 let spec = ChildSpec::new("my-server")?;
341
342 assert_eq!(spec.program(), std::ffi::OsStr::new("my-server"));
343 assert!(spec.args().is_empty());
344 Ok(())
345 }
346
347 /// The environment is inherited unless a caller takes it away, because a
348 /// child that cannot find `node` is not a useful default.
349 #[test]
350 fn the_environment_is_inherited_by_default() -> Result<(), Failure> {
351 let inherited = ChildSpec::new("my-server")?;
352 let cleared = ChildSpec::new("my-server")?.without_inherited_env();
353
354 assert!(inherited.inherits_env());
355 assert!(!cleared.inherits_env());
356 Ok(())
357 }
358
359 /// An argv is split the way every platform already splits one: the first
360 /// element is the program and the rest are arguments.
361 #[test]
362 fn an_argv_splits_at_the_first_element() -> Result<(), Failure> {
363 let spec = ChildSpec::from_argv(["my-server", "--stdio", "--quiet"])?;
364
365 assert_eq!(spec.program(), std::ffi::OsStr::new("my-server"));
366 assert_eq!(
367 spec.args(),
368 [OsString::from("--stdio"), OsString::from("--quiet")]
369 );
370 Ok(())
371 }
372
373 /// An empty argv has nothing to run, which is a different failure from an
374 /// argv whose program is empty, and worth telling apart: the first is a
375 /// caller with nothing, the second is a caller with a broken name.
376 #[test]
377 fn an_empty_argv_is_refused() {
378 let argv: [&str; 0] = [];
379
380 assert_matches!(ChildSpec::from_argv(argv), Err(ChildSpecError::EmptyArgv));
381 }
382
383 /// An empty program reaches the kernel as "run nothing" on some platforms
384 /// and as an error on others, so it is refused here where the answer is the
385 /// same everywhere.
386 #[test]
387 fn an_empty_program_is_refused() {
388 assert_matches!(ChildSpec::new(""), Err(ChildSpecError::EmptyProgram));
389 }
390
391 /// A NUL terminates the C string on its way into the kernel, so an argument
392 /// carrying one is silently truncated at the spawn. Refusing it here is the
393 /// difference between an error naming the field and a server started with
394 /// the wrong path.
395 #[test]
396 fn a_nul_in_an_argument_is_refused() -> Result<(), Failure> {
397 let spec = ChildSpec::new("my-server")?;
398
399 assert_matches!(
400 spec.with_arg("before\0after"),
401 Err(ChildSpecError::Unrepresentable {
402 what: "an argument"
403 })
404 );
405 Ok(())
406 }
407
408 /// `=` is what separates a key from its value, so a key containing one has
409 /// no value to separate it from.
410 #[test]
411 fn an_env_key_with_a_separator_is_refused() -> Result<(), Failure> {
412 let spec = ChildSpec::new("my-server")?;
413
414 assert_matches!(
415 spec.with_env("A=B", "value"),
416 Err(ChildSpecError::EnvKeyContainsSeparator)
417 );
418 Ok(())
419 }
420
421 /// The same key twice is a replacement, not a duplicate: two entries with
422 /// one name would leave the child to pick, and which one it picked would
423 /// depend on the platform's environment handling.
424 #[test]
425 fn a_repeated_env_key_is_replaced() -> Result<(), Failure> {
426 let spec = ChildSpec::new("my-server")?
427 .with_env("MODE", "first")?
428 .with_env("MODE", "second")?;
429
430 assert_eq!(spec.env().len(), 1);
431 assert_eq!(
432 spec.env().first(),
433 Some(&(OsString::from("MODE"), OsString::from("second")))
434 );
435 Ok(())
436 }
437
438 /// A NUL anywhere in the argument list refuses the whole list rather than
439 /// silently dropping the argument that carried it, so a caller that
440 /// ignored the error would know it had lost an argument.
441 #[test]
442 fn a_nul_among_several_arguments_refuses_the_whole_list() -> Result<(), Failure> {
443 let spec = ChildSpec::new("my-server")?;
444
445 assert_matches!(
446 spec.with_args(["--stdio", "bad\0argument"]),
447 Err(ChildSpecError::Unrepresentable {
448 what: "an argument"
449 })
450 );
451 Ok(())
452 }
453
454 /// A command built from a spec carries the program, the arguments and the
455 /// environment, and nothing else: the pipes and the kill-on-drop flag are
456 /// the spawn's business, not the spec's.
457 #[test]
458 fn a_command_carries_the_spec() -> Result<(), Failure> {
459 let spec = ChildSpec::new("my-server")?
460 .with_args(["--stdio"])?
461 .with_env("MODE", "test")?;
462
463 let command = spec.to_command();
464
465 assert_eq!(
466 command.as_std().get_program(),
467 std::ffi::OsStr::new("my-server")
468 );
469 // `get_args` hands back a `CommandArgs`, which iterates but is not
470 // `PartialEq`, so the comparison is against what it yields.
471 assert_eq!(
472 command.as_std().get_args().collect::<Vec<_>>(),
473 [std::ffi::OsStr::new("--stdio")]
474 );
475 Ok(())
476 }
477}