Skip to main content

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}