Skip to main content

codex_config/
mcp_requirements.rs

1use crate::mcp_types::McpServerConfig;
2use crate::mcp_types::McpServerTransportConfig;
3use regex_lite::Regex;
4use serde::Deserialize;
5
6#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
7#[serde(untagged)]
8pub enum McpServerIdentity {
9    Command { command: String },
10    Url { url: String },
11}
12
13/// String matching operations available to managed MCP server matchers.
14#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
15#[serde(tag = "match", rename_all = "snake_case", deny_unknown_fields)]
16pub enum McpServerValueMatcher {
17    Exact { value: String },
18    Prefix { value: String },
19    Regex { expression: String },
20}
21
22impl McpServerValueMatcher {
23    fn compile_full_regex(expression: &str) -> Result<Regex, String> {
24        Regex::new(&format!(r"\A(?:{expression})\z")).map_err(|err| {
25            format!("regex `{expression}` cannot be used for full-value matching: {err}")
26        })
27    }
28
29    fn validate(&self) -> Result<(), String> {
30        let Self::Regex { expression } = self else {
31            return Ok(());
32        };
33
34        Regex::new(expression).map_err(|err| format!("invalid regex `{expression}`: {err}"))?;
35        Self::compile_full_regex(expression).map(|_| ())
36    }
37
38    fn matches(&self, candidate: &str) -> bool {
39        match self {
40            Self::Exact { value } => candidate == value,
41            Self::Prefix { value } => candidate.starts_with(value),
42            Self::Regex { expression } => Self::compile_full_regex(expression)
43                .ok()
44                .is_some_and(|regex| regex.is_match(candidate)),
45        }
46    }
47}
48
49#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
50#[serde(deny_unknown_fields)]
51pub struct McpServerCommandMatcher {
52    pub executable: String,
53    pub args: Vec<McpServerValueMatcher>,
54}
55
56#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
57#[serde(deny_unknown_fields)]
58struct RawMcpServerCommandIdentity {
59    command: McpServerCommandMatcher,
60}
61
62#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
63#[serde(deny_unknown_fields)]
64struct RawMcpServerUrlIdentity {
65    url: McpServerValueMatcher,
66}
67
68/// A requirement for one named MCP server.
69///
70/// The `Identity` variant preserves the released exact-match contract. The
71/// command and URL variants are the normalized matcher-based forms accepted
72/// under the `identity` key.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum McpServerRequirement {
75    Identity { identity: McpServerIdentity },
76    Command(McpServerCommandMatcher),
77    Url(McpServerValueMatcher),
78}
79
80#[derive(Deserialize)]
81struct RawMcpServerRequirement {
82    identity: RawMcpServerIdentity,
83}
84
85#[derive(Deserialize)]
86#[serde(untagged)]
87enum RawMcpServerIdentity {
88    Exact(McpServerIdentity),
89    Command(RawMcpServerCommandIdentity),
90    Url(RawMcpServerUrlIdentity),
91}
92
93impl<'de> Deserialize<'de> for McpServerRequirement {
94    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
95    where
96        D: serde::Deserializer<'de>,
97    {
98        let RawMcpServerRequirement { identity } =
99            RawMcpServerRequirement::deserialize(deserializer)?;
100        match identity {
101            RawMcpServerIdentity::Exact(identity) => Ok(Self::Identity { identity }),
102            RawMcpServerIdentity::Command(matcher) => Ok(Self::Command(matcher.command)),
103            RawMcpServerIdentity::Url(matcher) => Ok(Self::Url(matcher.url)),
104        }
105    }
106}
107
108impl McpServerRequirement {
109    pub(crate) fn validate(&self) -> Result<(), String> {
110        match self {
111            Self::Identity { .. } => Ok(()),
112            Self::Command(matcher) => {
113                for (index, arg) in matcher.args.iter().enumerate() {
114                    arg.validate().map_err(|err| {
115                        format!("invalid argument matcher at index {index}: {err}")
116                    })?;
117                }
118                Ok(())
119            }
120            Self::Url(matcher) => matcher.validate(),
121        }
122    }
123
124    pub fn matches(&self, server: &McpServerConfig) -> bool {
125        match (self, &server.transport) {
126            (
127                Self::Identity {
128                    identity:
129                        McpServerIdentity::Command {
130                            command: want_command,
131                        },
132                },
133                McpServerTransportConfig::Stdio {
134                    command: got_command,
135                    ..
136                },
137            ) => got_command == want_command,
138            (
139                Self::Identity {
140                    identity: McpServerIdentity::Url { url: want_url },
141                },
142                McpServerTransportConfig::StreamableHttp { url: got_url, .. },
143            ) => got_url == want_url,
144            (Self::Command(matcher), McpServerTransportConfig::Stdio { command, args, .. }) => {
145                matcher.executable == *command
146                    && matcher.args.len() == args.len()
147                    && matcher
148                        .args
149                        .iter()
150                        .zip(args)
151                        .all(|(matcher, arg)| matcher.matches(arg))
152            }
153            (Self::Url(matcher), McpServerTransportConfig::StreamableHttp { url, .. }) => {
154                matcher.matches(url)
155            }
156            _ => false,
157        }
158    }
159}
160
161#[cfg(test)]
162#[path = "mcp_requirements_tests.rs"]
163mod tests;