@noorslvt/shev 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,563 @@
1
+ //! Read-only reuse of existing MCP registrations. Configuration is never edited.
2
+ use crate::{home_dir, opencode_configs, opencode_project_configs, parse_jsonc};
3
+ use serde_json::{Value, json};
4
+ use std::collections::BTreeMap;
5
+ use std::io::{BufRead, BufReader, Write};
6
+ use std::path::PathBuf;
7
+ use std::process::{Child, ChildStdin, Command, Stdio};
8
+ use std::sync::mpsc::{self, Receiver};
9
+ use std::time::{Duration, Instant};
10
+
11
+ const TIMEOUT: Duration = Duration::from_secs(30);
12
+ const LIMIT: usize = 256 * 1024;
13
+
14
+ #[derive(Clone)]
15
+ pub struct Server {
16
+ pub name: String,
17
+ pub source: String,
18
+ config: Value,
19
+ }
20
+
21
+ pub fn servers() -> Vec<Server> {
22
+ let codex = std::env::var_os("CODEX_HOME")
23
+ .map(PathBuf::from)
24
+ .unwrap_or_else(|| home_dir().join(".codex"));
25
+ let mut found = BTreeMap::new();
26
+ if let Ok(text) = std::fs::read_to_string(codex.join("config.toml"))
27
+ && let Ok(value) = text.parse::<toml::Value>()
28
+ && let Some(entries) = value.get("mcp_servers").and_then(toml::Value::as_table)
29
+ {
30
+ for (name, cfg) in entries {
31
+ if let Ok(config) = serde_json::to_value(cfg) {
32
+ found.insert(
33
+ name.clone(),
34
+ Server {
35
+ name: name.clone(),
36
+ source: "codex".into(),
37
+ config,
38
+ },
39
+ );
40
+ }
41
+ }
42
+ }
43
+ for path in opencode_configs()
44
+ .into_iter()
45
+ .chain(opencode_project_configs())
46
+ {
47
+ let Some(value) = std::fs::read_to_string(&path)
48
+ .ok()
49
+ .and_then(|t| parse_jsonc(&t))
50
+ else {
51
+ continue;
52
+ };
53
+ let mcp = value.get("mcp").unwrap_or(&Value::Null);
54
+ let Some(entries) = mcp.get("servers").unwrap_or(mcp).as_object() else {
55
+ continue;
56
+ };
57
+ for (name, config) in entries {
58
+ if config.get("enabled") == Some(&json!(false)) {
59
+ found.remove(name);
60
+ continue;
61
+ }
62
+ if !config.is_object() {
63
+ continue;
64
+ }
65
+ found.insert(
66
+ name.clone(),
67
+ Server {
68
+ name: name.clone(),
69
+ source: "opencode".into(),
70
+ config: config.clone(),
71
+ },
72
+ );
73
+ }
74
+ }
75
+ found
76
+ .into_values()
77
+ .filter(|s| s.config.get("enabled") != Some(&json!(false)))
78
+ .collect()
79
+ }
80
+
81
+ /// Only tools explicitly declared read-only and non-destructive are offered.
82
+ /// These declarations come from a trusted configured server, not from the model.
83
+ pub fn read_only(tool: &Value) -> bool {
84
+ tool.pointer("/annotations/readOnlyHint")
85
+ .and_then(Value::as_bool)
86
+ == Some(true)
87
+ && tool
88
+ .pointer("/annotations/destructiveHint")
89
+ .and_then(Value::as_bool)
90
+ != Some(true)
91
+ }
92
+
93
+ fn expand(value: &str) -> Result<String, String> {
94
+ let mut text = value.to_string();
95
+ for _ in 0..32 {
96
+ let Some(start) = text.find("{env:") else {
97
+ return Ok(text);
98
+ };
99
+ let end = text[start..]
100
+ .find('}')
101
+ .ok_or("invalid environment reference")?
102
+ + start;
103
+ let name = &text[start + 5..end];
104
+ let replacement =
105
+ std::env::var(name).map_err(|_| "an MCP environment variable is missing")?;
106
+ text.replace_range(start..=end, &replacement);
107
+ }
108
+ Err("recursive environment reference in MCP configuration".into())
109
+ }
110
+
111
+ struct Local {
112
+ child: Child,
113
+ stdin: ChildStdin,
114
+ lines: Receiver<Result<String, String>>,
115
+ }
116
+
117
+ impl Drop for Local {
118
+ fn drop(&mut self) {
119
+ let _ = self.child.kill();
120
+ let _ = self.child.wait();
121
+ }
122
+ }
123
+
124
+ enum Transport {
125
+ Local(Local),
126
+ Remote {
127
+ client: reqwest::blocking::Client,
128
+ url: String,
129
+ session: Option<String>,
130
+ },
131
+ }
132
+
133
+ impl Transport {
134
+ fn open(server: &Server) -> Result<Self, String> {
135
+ let cfg = &server.config;
136
+ if let Some(url) = cfg.get("url").and_then(Value::as_str) {
137
+ let mut headers = reqwest::header::HeaderMap::new();
138
+ if let Some(values) = cfg
139
+ .get("headers")
140
+ .or_else(|| cfg.get("http_headers"))
141
+ .and_then(Value::as_object)
142
+ {
143
+ for (name, value) in values {
144
+ let name = reqwest::header::HeaderName::from_bytes(name.as_bytes())
145
+ .map_err(|_| "invalid MCP header name")?;
146
+ let value = expand(value.as_str().ok_or("invalid MCP header")?)?;
147
+ headers.insert(name, value.parse().map_err(|_| "invalid MCP header value")?);
148
+ }
149
+ }
150
+ if let Some(name) = cfg.get("bearer_token_env_var").and_then(Value::as_str) {
151
+ let token = std::env::var(name)
152
+ .map_err(|_| "MCP bearer-token environment variable is missing")?;
153
+ headers.insert(
154
+ reqwest::header::AUTHORIZATION,
155
+ format!("Bearer {token}")
156
+ .parse()
157
+ .map_err(|_| "invalid MCP bearer token")?,
158
+ );
159
+ }
160
+ if let Some(values) = cfg.get("env_http_headers").and_then(Value::as_object) {
161
+ for (name, variable) in values {
162
+ let name = reqwest::header::HeaderName::from_bytes(name.as_bytes())
163
+ .map_err(|_| "invalid MCP header name")?;
164
+ let value = std::env::var(
165
+ variable
166
+ .as_str()
167
+ .ok_or("invalid MCP header environment reference")?,
168
+ )
169
+ .map_err(|_| "MCP header environment variable is missing")?;
170
+ headers.insert(name, value.parse().map_err(|_| "invalid MCP header value")?);
171
+ }
172
+ }
173
+ let client = reqwest::blocking::Client::builder()
174
+ .default_headers(headers)
175
+ .timeout(TIMEOUT)
176
+ .redirect(reqwest::redirect::Policy::none())
177
+ .build()
178
+ .map_err(|_| "could not build MCP client")?;
179
+ return Ok(Self::Remote {
180
+ client,
181
+ url: expand(url)?,
182
+ session: None,
183
+ });
184
+ }
185
+ let command = cfg
186
+ .get("command")
187
+ .ok_or("MCP server has no command or URL")?;
188
+ let mut args: Vec<String> = if let Some(parts) = command.as_array() {
189
+ parts
190
+ .iter()
191
+ .map(|s| expand(s.as_str().ok_or("invalid MCP command")?))
192
+ .collect::<Result<_, _>>()?
193
+ } else {
194
+ vec![expand(command.as_str().ok_or("invalid MCP command")?)?]
195
+ };
196
+ if let Some(extra) = cfg.get("args").and_then(Value::as_array) {
197
+ for arg in extra {
198
+ args.push(expand(arg.as_str().ok_or("invalid MCP argument")?)?);
199
+ }
200
+ }
201
+ let (program, args) = args.split_first().ok_or("empty MCP command")?;
202
+ let mut command = Command::new(program);
203
+ command
204
+ .args(args)
205
+ .stdin(Stdio::piped())
206
+ .stdout(Stdio::piped())
207
+ .stderr(Stdio::null());
208
+ if let Some(cwd) = cfg.get("cwd").and_then(Value::as_str) {
209
+ command.current_dir(expand(cwd)?);
210
+ }
211
+ if let Some(env) = cfg
212
+ .get("environment")
213
+ .or_else(|| cfg.get("env"))
214
+ .and_then(Value::as_object)
215
+ {
216
+ for (key, value) in env {
217
+ command.env(
218
+ key,
219
+ expand(value.as_str().ok_or("invalid MCP environment")?)?,
220
+ );
221
+ }
222
+ }
223
+ let mut child = command
224
+ .spawn()
225
+ .map_err(|_| "could not launch configured MCP server")?;
226
+ let stdin = child.stdin.take().ok_or("MCP stdin unavailable")?;
227
+ let stdout = child.stdout.take().ok_or("MCP stdout unavailable")?;
228
+ let (tx, lines) = mpsc::sync_channel(16);
229
+ std::thread::spawn(move || {
230
+ let mut reader = BufReader::new(stdout);
231
+ loop {
232
+ let mut line = String::new();
233
+ // Bound each protocol message; never collect unlimited server output.
234
+ let result =
235
+ std::io::Read::take(&mut reader, LIMIT as u64 + 1).read_line(&mut line);
236
+ match result {
237
+ Ok(0) | Err(_) => break,
238
+ Ok(_) if line.len() > LIMIT => {
239
+ let _ = tx.send(Err("MCP message is too large".into()));
240
+ break;
241
+ }
242
+ Ok(_) => {
243
+ if tx.send(Ok(line)).is_err() {
244
+ break;
245
+ }
246
+ }
247
+ }
248
+ }
249
+ });
250
+ Ok(Self::Local(Local {
251
+ child,
252
+ stdin,
253
+ lines,
254
+ }))
255
+ }
256
+
257
+ fn send(&mut self, request: Value) -> Result<Value, String> {
258
+ let id = request.get("id").cloned();
259
+ match self {
260
+ Self::Local(local) => {
261
+ writeln!(local.stdin, "{request}")
262
+ .and_then(|()| local.stdin.flush())
263
+ .map_err(|_| "MCP write failed")?;
264
+ let Some(id) = id else { return Ok(Value::Null) };
265
+ let start = Instant::now();
266
+ loop {
267
+ let line = local
268
+ .lines
269
+ .recv_timeout(TIMEOUT.saturating_sub(start.elapsed()))
270
+ .map_err(|_| "MCP response timed out or server disconnected")??;
271
+ let value: Value =
272
+ serde_json::from_str(&line).map_err(|_| "MCP server sent invalid JSON")?;
273
+ if value.get("id") == Some(&id) {
274
+ return response(value);
275
+ }
276
+ if value.get("id").is_some() && value.get("method").is_some() {
277
+ writeln!(local.stdin, "{}", json!({"jsonrpc":"2.0","id":value["id"],"error":{"code":-32601,"message":"Client requests not supported"}})).map_err(|_| "MCP write failed")?;
278
+ }
279
+ }
280
+ }
281
+ Self::Remote {
282
+ client,
283
+ url,
284
+ session,
285
+ } => {
286
+ let mut req = client
287
+ .post(url.as_str())
288
+ .header("Accept", "application/json, text/event-stream")
289
+ .json(&request);
290
+ if let Some(value) = session.as_ref() {
291
+ req = req
292
+ .header("Mcp-Session-Id", value)
293
+ .header("MCP-Protocol-Version", "2025-03-26");
294
+ }
295
+ let reply = req.send().map_err(|_| "MCP connection failed")?;
296
+ let status = reply.status();
297
+ if status.as_u16() == 401 || status.as_u16() == 403 {
298
+ return Err("MCP authentication required; authenticate in its host, or configure an environment-based token (OAuth credentials are not copied)".into());
299
+ }
300
+ if !status.is_success() {
301
+ return Err(format!("MCP returned HTTP {}", status.as_u16()));
302
+ }
303
+ if let Some(value) = reply
304
+ .headers()
305
+ .get("Mcp-Session-Id")
306
+ .and_then(|h| h.to_str().ok())
307
+ {
308
+ *session = Some(value.to_string());
309
+ }
310
+ if id.is_none() {
311
+ return Ok(Value::Null);
312
+ }
313
+ let sse = reply
314
+ .headers()
315
+ .get(reqwest::header::CONTENT_TYPE)
316
+ .and_then(|h| h.to_str().ok())
317
+ .is_some_and(|v| v.contains("text/event-stream"));
318
+ let mut reader = BufReader::new(std::io::Read::take(reply, LIMIT as u64 + 1));
319
+ let mut text = String::new();
320
+ let mut total = 0;
321
+ loop {
322
+ let mut line = String::new();
323
+ let n = reader
324
+ .read_line(&mut line)
325
+ .map_err(|_| "MCP response read failed")?;
326
+ total += n;
327
+ if total > LIMIT {
328
+ return Err("MCP response is too large".into());
329
+ }
330
+ if !sse {
331
+ text.push_str(&line);
332
+ } else if let Some(data) = line.strip_prefix("data:") {
333
+ text.push_str(data.trim_start_matches(' ').trim_end_matches(['\r', '\n']));
334
+ text.push('\n');
335
+ } else if line.trim().is_empty() && !text.is_empty() {
336
+ if let Ok(value) = serde_json::from_str::<Value>(&text)
337
+ && value.get("id") == id.as_ref()
338
+ {
339
+ return response(value);
340
+ }
341
+ text.clear();
342
+ }
343
+ if n == 0 {
344
+ break;
345
+ }
346
+ }
347
+ let value: Value =
348
+ serde_json::from_str(&text).map_err(|_| "unsupported MCP response format")?;
349
+ if value.get("id") != id.as_ref() {
350
+ return Err("MCP response ID did not match".into());
351
+ }
352
+ response(value)
353
+ }
354
+ }
355
+ }
356
+
357
+ fn initialize(&mut self) -> Result<(), String> {
358
+ self.send(json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"shev","version":env!("CARGO_PKG_VERSION")}}}))?;
359
+ self.send(json!({"jsonrpc":"2.0","method":"notifications/initialized"}))?;
360
+ Ok(())
361
+ }
362
+
363
+ fn tools(&mut self) -> Result<Vec<Value>, String> {
364
+ let mut tools = Vec::new();
365
+ let mut cursor = Value::Null;
366
+ for id in 2..=101 {
367
+ let params = if cursor.is_null() {
368
+ json!({})
369
+ } else {
370
+ json!({"cursor":cursor})
371
+ };
372
+ let result =
373
+ self.send(json!({"jsonrpc":"2.0","id":id,"method":"tools/list","params":params}))?;
374
+ tools.extend(
375
+ result
376
+ .get("tools")
377
+ .and_then(Value::as_array)
378
+ .cloned()
379
+ .ok_or("MCP server returned no tool list")?,
380
+ );
381
+ cursor = result.get("nextCursor").cloned().unwrap_or(Value::Null);
382
+ if cursor.is_null() {
383
+ return Ok(tools);
384
+ }
385
+ }
386
+ Err("MCP tool pagination exceeded its limit".into())
387
+ }
388
+ }
389
+
390
+ fn response(value: Value) -> Result<Value, String> {
391
+ // Never echo raw server errors: they can include credentials or request data.
392
+ if value.get("error").is_some() {
393
+ return Err("MCP server rejected the request".into());
394
+ }
395
+ value
396
+ .get("result")
397
+ .cloned()
398
+ .ok_or_else(|| "MCP response has no result".into())
399
+ }
400
+
401
+ pub fn list(server: &Server) -> Result<Vec<Value>, String> {
402
+ let mut transport = Transport::open(server)?;
403
+ transport.initialize()?;
404
+ transport.tools()
405
+ }
406
+
407
+ pub fn call(server: &Server, name: &str, arguments: Value) -> Result<Value, String> {
408
+ if !arguments.is_object() {
409
+ return Err("tool arguments must be a JSON object".into());
410
+ }
411
+ let mut transport = Transport::open(server)?;
412
+ transport.initialize()?;
413
+ let tools = transport.tools()?;
414
+ let tool = tools
415
+ .iter()
416
+ .find(|t| t.get("name").and_then(Value::as_str) == Some(name))
417
+ .ok_or("tool not found")?;
418
+ if !read_only(tool) {
419
+ return Err(
420
+ "blocked: the server does not declare this tool read-only and non-destructive".into(),
421
+ );
422
+ }
423
+ let result = transport.send(json!({"jsonrpc":"2.0","id":1000,"method":"tools/call","params":{"name":name,"arguments":arguments}}))?;
424
+ if result.get("isError") == Some(&json!(true)) {
425
+ return Err("MCP tool reported a failure".into());
426
+ }
427
+ Ok(result)
428
+ }
429
+
430
+ #[cfg(test)]
431
+ mod tests {
432
+ use super::*;
433
+ #[test]
434
+ fn unknown_and_mutating_tools_are_blocked() {
435
+ assert!(!read_only(&json!({"name":"read"})));
436
+ assert!(!read_only(
437
+ &json!({"annotations":{"readOnlyHint":true,"destructiveHint":true}})
438
+ ));
439
+ assert!(read_only(
440
+ &json!({"annotations":{"readOnlyHint":true,"destructiveHint":false}})
441
+ ));
442
+ }
443
+ #[test]
444
+ fn protocol_errors_do_not_leak_server_messages() {
445
+ assert!(
446
+ !response(json!({"error":{"message":"secret"}}))
447
+ .unwrap_err()
448
+ .contains("secret")
449
+ );
450
+ }
451
+
452
+ #[test]
453
+ fn stdio_tools_are_really_called_and_mutations_never_reach_the_server() {
454
+ if Command::new("python3")
455
+ .arg("--version")
456
+ .stdout(Stdio::null())
457
+ .stderr(Stdio::null())
458
+ .status()
459
+ .is_err()
460
+ {
461
+ return;
462
+ }
463
+ let script = r#"
464
+ import sys, json
465
+ for line in sys.stdin:
466
+ q = json.loads(line)
467
+ if 'id' not in q: continue
468
+ method = q['method']
469
+ if method == 'initialize': result = {'protocolVersion':'2025-03-26','capabilities':{'tools':{}},'serverInfo':{'name':'fixture','version':'1'}}
470
+ elif method == 'tools/list':
471
+ if q.get('params',{}).get('cursor') == 'page2':
472
+ result = {'tools':[{'name':'erase','inputSchema':{'type':'object'},'annotations':{'readOnlyHint':False,'destructiveHint':True}}]}
473
+ else:
474
+ result = {'tools':[{'name':'lookup','inputSchema':{'type':'object'},'annotations':{'readOnlyHint':True,'destructiveHint':False}}], 'nextCursor':'page2'}
475
+ elif method == 'tools/call':
476
+ assert q['params']['name'] == 'lookup', 'MUTATING TOOL WAS CALLED'
477
+ result = {'content':[{'type':'text','text':q['params']['arguments']['query']}], 'isError':False}
478
+ else: raise AssertionError(method)
479
+ print(json.dumps({'jsonrpc':'2.0','id':q['id'],'result':result}), flush=True)
480
+ "#;
481
+ let server = Server {
482
+ name: "fixture".into(),
483
+ source: "test".into(),
484
+ config: json!({"command":["python3","-u","-c",script]}),
485
+ };
486
+ assert_eq!(list(&server).unwrap().len(), 2);
487
+ let output = call(&server, "lookup", json!({"query":"real result"})).unwrap();
488
+ assert_eq!(output["content"][0]["text"], "real result");
489
+ let error = call(&server, "erase", json!({})).unwrap_err();
490
+ assert!(error.starts_with("blocked:"), "{error}");
491
+ assert!(
492
+ call(&server, "missing", json!({}))
493
+ .unwrap_err()
494
+ .contains("not found")
495
+ );
496
+ }
497
+
498
+ #[test]
499
+ fn http_streamable_mcp_reuses_session_and_reads_sse() {
500
+ use std::io::Read;
501
+ use std::net::TcpListener;
502
+ let listener = TcpListener::bind("127.0.0.1:0").unwrap();
503
+ let address = listener.local_addr().unwrap();
504
+ let thread = std::thread::spawn(move || {
505
+ for request_index in 0..4 {
506
+ let (mut socket, _) = listener.accept().unwrap();
507
+ socket
508
+ .set_read_timeout(Some(Duration::from_secs(5)))
509
+ .unwrap();
510
+ let mut reader = BufReader::new(socket.try_clone().unwrap());
511
+ let mut length = 0;
512
+ let mut session = false;
513
+ loop {
514
+ let mut line = String::new();
515
+ reader.read_line(&mut line).unwrap();
516
+ let lower = line.to_lowercase();
517
+ if let Some(n) = lower.strip_prefix("content-length:") {
518
+ length = n.trim().parse().unwrap();
519
+ }
520
+ if lower.starts_with("mcp-session-id: fixture") {
521
+ session = true;
522
+ }
523
+ if line == "\r\n" {
524
+ break;
525
+ }
526
+ }
527
+ if request_index > 0 {
528
+ assert!(session);
529
+ }
530
+ let mut body = vec![0; length];
531
+ reader.read_exact(&mut body).unwrap();
532
+ let q: Value = serde_json::from_slice(&body).unwrap();
533
+ let result = match q["method"].as_str().unwrap() {
534
+ "initialize" => {
535
+ json!({"protocolVersion":"2025-03-26","capabilities":{"tools":{}},"serverInfo":{"name":"fixture","version":"1"}})
536
+ }
537
+ "notifications/initialized" => {
538
+ write!(socket, "HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").unwrap();
539
+ continue;
540
+ }
541
+ "tools/list" => {
542
+ json!({"tools":[{"name":"lookup","inputSchema":{"type":"object"},"annotations":{"readOnlyHint":true}}]})
543
+ }
544
+ "tools/call" => json!({"content":[{"type":"text","text":"HTTP result"}]}),
545
+ _ => panic!("unexpected MCP method"),
546
+ };
547
+ let event = format!(
548
+ "data: {}\n\n",
549
+ json!({"jsonrpc":"2.0","id":q["id"],"result":result})
550
+ );
551
+ write!(socket, "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nMcp-Session-Id: fixture\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", event.len(), event).unwrap();
552
+ }
553
+ });
554
+ let server = Server {
555
+ name: "fixture".into(),
556
+ source: "test".into(),
557
+ config: json!({"url":format!("http://{address}")}),
558
+ };
559
+ let result = call(&server, "lookup", json!({})).unwrap();
560
+ assert_eq!(result["content"][0]["text"], "HTTP result");
561
+ thread.join().unwrap();
562
+ }
563
+ }