1mod client;
2mod decisions;
3mod server;
4mod tools;
5
6use self::client::run_client_to_server;
7use self::server::run_server_to_client;
8use super::decision::{DecisionEmitter, FileDecisionEmitter, NullDecisionEmitter};
9use super::policy::McpPolicy;
10use super::tool_definition::ToolDefinitionBinding;
11use std::{
12 collections::HashMap,
13 io,
14 process::{Child, Command, Stdio},
15 sync::{Arc, Mutex},
16 thread,
17};
18
19#[derive(Clone, Debug)]
23pub struct ProxyConfig {
24 pub dry_run: bool,
25 pub verbose: bool,
26 pub audit_log_path: Option<std::path::PathBuf>,
28 pub server_id: String,
29 pub decision_log_path: Option<std::path::PathBuf>,
31 pub event_source: Option<String>,
33 pub tdt_producer: Option<TdtProducer>,
37}
38
39#[derive(Clone)]
48pub struct TdtProducer {
49 out_path: std::path::PathBuf,
50 key: Vec<u8>,
51 key_id: String,
52}
53
54impl TdtProducer {
55 pub fn new(out_path: std::path::PathBuf, key: Vec<u8>, key_id: String) -> Self {
60 Self {
61 out_path,
62 key,
63 key_id,
64 }
65 }
66
67 pub(crate) fn out_path(&self) -> &std::path::Path {
69 &self.out_path
70 }
71
72 pub(crate) fn key(&self) -> &[u8] {
74 &self.key
75 }
76
77 pub(crate) fn key_id(&self) -> &str {
79 &self.key_id
80 }
81}
82
83impl std::fmt::Debug for TdtProducer {
84 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85 f.debug_struct("TdtProducer")
86 .field("out_path", &self.out_path)
87 .field("key", &"<redacted>")
88 .field("key_id", &self.key_id)
89 .finish()
90 }
91}
92
93#[derive(Clone, Debug, Default)]
95pub struct ProxyConfigRaw {
96 pub dry_run: bool,
97 pub verbose: bool,
98 pub audit_log_path: Option<std::path::PathBuf>,
99 pub server_id: String,
100 pub decision_log_path: Option<std::path::PathBuf>,
101 pub event_source: Option<String>,
102}
103
104impl ProxyConfig {
105 pub fn try_from_raw(raw: ProxyConfigRaw) -> anyhow::Result<Self> {
111 let logging_enabled = raw.audit_log_path.is_some() || raw.decision_log_path.is_some();
112
113 let event_source = raw
114 .event_source
115 .map(|s| s.trim().to_string())
116 .filter(|s| !s.is_empty());
117
118 if logging_enabled && event_source.is_none() {
119 anyhow::bail!(
120 "event_source is required when logging is enabled (e.g. --event-source assay://org/app)"
121 );
122 }
123
124 if let Some(ref src) = event_source {
125 validate_event_source(src)?;
126 }
127
128 Ok(ProxyConfig {
129 dry_run: raw.dry_run,
130 verbose: raw.verbose,
131 audit_log_path: raw.audit_log_path,
132 server_id: raw.server_id,
133 decision_log_path: raw.decision_log_path,
134 event_source,
135 tdt_producer: None,
138 })
139 }
140}
141
142fn validate_event_source(s: &str) -> anyhow::Result<()> {
144 let s = s.trim();
145 if s.is_empty() {
146 anyhow::bail!("event_source must be absolute URI with scheme (e.g. assay://org/app)");
147 }
148 if s.chars().any(|c| c.is_whitespace()) {
149 anyhow::bail!("event_source must not contain whitespace");
150 }
151
152 let Some(pos) = s.find("://") else {
154 anyhow::bail!("event_source must be absolute URI with scheme (e.g. assay://org/app)");
155 };
156 if pos == 0 {
157 anyhow::bail!("event_source must have scheme before :// (e.g. assay://org/app)");
158 }
159
160 let scheme = &s[..pos];
162 let mut chars = scheme.chars();
163 match chars.next() {
164 Some(c) if c.is_ascii_alphabetic() => {}
165 _ => anyhow::bail!("event_source URI scheme must start with a letter"),
166 }
167 if !chars.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') {
168 anyhow::bail!("event_source URI scheme contains invalid characters");
169 }
170
171 Ok(())
172}
173
174pub struct McpProxy {
175 child: Child,
176 policy: McpPolicy,
177 config: ProxyConfig,
178 identity_cache: Arc<Mutex<HashMap<String, super::identity::ToolIdentity>>>,
180 tool_definition_cache: Arc<Mutex<HashMap<String, ToolDefinitionBinding>>>,
182}
183
184impl Drop for McpProxy {
185 fn drop(&mut self) {
186 let _ = self.child.kill();
188 }
189}
190
191impl McpProxy {
192 pub fn spawn(
193 command: &str,
194 args: &[String],
195 policy: McpPolicy,
196 config: ProxyConfig,
197 ) -> io::Result<Self> {
198 let child = Command::new(command)
199 .args(args)
200 .stdin(Stdio::piped())
201 .stdout(Stdio::piped())
202 .stderr(Stdio::inherit()) .spawn()?;
204
205 Ok(Self {
206 child,
207 policy,
208 config,
209 identity_cache: Arc::new(Mutex::new(HashMap::new())),
210 tool_definition_cache: Arc::new(Mutex::new(HashMap::new())),
211 })
212 }
213
214 pub fn run(mut self) -> io::Result<i32> {
215 let child_stdin = self.child.stdin.take().expect("child stdin");
216 let child_stdout = self.child.stdout.take().expect("child stdout");
217
218 let stdout = Arc::new(Mutex::new(io::stdout()));
219 let policy = self.policy.clone();
220 let config = self.config.clone();
221 let identity_cache_a = self.identity_cache.clone();
222 let identity_cache_b = self.identity_cache.clone();
223 let tool_definition_cache_a = self.tool_definition_cache.clone();
224 let tool_definition_cache_b = self.tool_definition_cache.clone();
225
226 let decision_emitter: Arc<dyn DecisionEmitter> =
228 if let Some(path) = &config.decision_log_path {
229 Arc::new(FileDecisionEmitter::new(path)?)
230 } else {
231 Arc::new(NullDecisionEmitter)
232 };
233 let event_source = config
234 .event_source
235 .clone()
236 .unwrap_or_else(|| format!("assay://{}", config.server_id));
237
238 let stdout_a = stdout.clone();
240 let server_id_a = config.server_id.clone();
241 let t_server_to_client = thread::spawn(move || {
242 run_server_to_client(
243 child_stdout,
244 stdout_a,
245 server_id_a,
246 identity_cache_a,
247 tool_definition_cache_a,
248 )
249 });
250
251 let stdout_b = stdout.clone();
253 let emitter_b = decision_emitter.clone();
254 let event_source_b = event_source.clone();
255 let t_client_to_server = thread::spawn(move || {
256 run_client_to_server(
257 child_stdin,
258 stdout_b,
259 policy,
260 config,
261 emitter_b,
262 event_source_b,
263 identity_cache_b,
264 tool_definition_cache_b,
265 )
266 });
267
268 t_client_to_server
270 .join()
271 .map_err(|_| io::Error::other("client->server thread panicked"))??;
272
273 let _ = t_server_to_client.join();
275
276 let status = self.child.wait()?;
278 Ok(status.code().unwrap_or(1))
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 #[test]
287 fn event_source_accepts_assay_uri() {
288 validate_event_source("assay://myorg/myapp").unwrap();
289 }
290
291 #[test]
292 fn event_source_accepts_https_uri() {
293 validate_event_source("https://example.com/agent").unwrap();
294 }
295
296 #[test]
297 fn event_source_rejects_empty() {
298 assert!(validate_event_source("").is_err());
299 assert!(validate_event_source(" ").is_err());
300 }
301
302 #[test]
303 fn event_source_rejects_whitespace() {
304 assert!(validate_event_source("assay://myorg/my app").is_err());
305 assert!(validate_event_source("assay://myorg/\tmyapp").is_err());
306 }
307
308 #[test]
309 fn event_source_rejects_missing_scheme() {
310 assert!(validate_event_source("myorg/myapp").is_err());
311 assert!(validate_event_source("://myorg/myapp").is_err());
312 }
313
314 #[test]
315 fn event_source_rejects_did_and_urn() {
316 assert!(validate_event_source("did:example:123").is_err());
318 assert!(validate_event_source("urn:example:foo").is_err());
319 }
320
321 #[test]
322 fn event_source_rejects_scheme_starting_with_non_letter() {
323 assert!(validate_event_source("1assay://myorg/myapp").is_err());
324 assert!(validate_event_source("-assay://myorg/myapp").is_err());
325 }
326
327 #[test]
328 fn event_source_rejects_scheme_with_invalid_chars() {
329 assert!(validate_event_source("as_say://myorg/myapp").is_err());
330 assert!(validate_event_source("as@say://myorg/myapp").is_err());
331 }
332
333 #[test]
334 fn config_requires_event_source_when_logging_enabled() {
335 let raw = ProxyConfigRaw {
336 dry_run: false,
337 verbose: false,
338 audit_log_path: None,
339 decision_log_path: Some(std::path::PathBuf::from("decisions.ndjson")),
340 event_source: None,
341 server_id: "srv".to_string(),
342 };
343
344 let err = ProxyConfig::try_from_raw(raw).unwrap_err();
345 let msg = format!("{err:#}");
346 assert!(msg.contains("event_source is required"));
347 }
348
349 #[test]
350 fn config_allows_no_event_source_when_logging_disabled() {
351 let raw = ProxyConfigRaw {
352 dry_run: false,
353 verbose: false,
354 audit_log_path: None,
355 decision_log_path: None,
356 event_source: None,
357 server_id: "srv".to_string(),
358 };
359
360 ProxyConfig::try_from_raw(raw).unwrap();
361 }
362
363 #[test]
364 fn config_accepts_valid_event_source() {
365 let raw = ProxyConfigRaw {
366 dry_run: false,
367 verbose: false,
368 audit_log_path: None,
369 decision_log_path: Some(std::path::PathBuf::from("decisions.ndjson")),
370 event_source: Some("assay://myorg/myapp".to_string()),
371 server_id: "srv".to_string(),
372 };
373
374 let cfg = ProxyConfig::try_from_raw(raw).unwrap();
375 assert_eq!(cfg.event_source.as_deref(), Some("assay://myorg/myapp"));
376 }
377
378 #[test]
379 fn config_rejects_invalid_event_source_uri() {
380 let raw = ProxyConfigRaw {
381 dry_run: false,
382 verbose: false,
383 audit_log_path: None,
384 decision_log_path: Some(std::path::PathBuf::from("decisions.ndjson")),
385 event_source: Some("not a uri".to_string()),
386 server_id: "srv".to_string(),
387 };
388
389 assert!(ProxyConfig::try_from_raw(raw).is_err());
390 }
391}