eb675dfa Andy McCall 2 days ago ↶
1
2
3
4
5
6
mod common ;
7
8
use common:: { add_agent, hermetic, setup, Serving} ;
9
use serde_json:: { json, Value} ;
10
use spectra_mcp:: client:: Client;
11
use std:: process:: Stdio;
12
use std:: time:: Duration;
13
use tokio:: io:: { AsyncBufReadExt, AsyncWriteExt, BufReader, Lines} ;
14
use tokio:: process:: { ChildStdin, ChildStdout} ;
15
16
struct Shim {
62d35f1d Andy McCall 2 days ago ↶
17
18
_child : tokio:: process:: Child,
eb675dfa Andy McCall 2 days ago ↶
19
stdin : ChildStdin,
20
stdout : Lines< BufReader< ChildStdout> > ,
21
}
22
23
impl Shim {
24
fn start ( d : & std:: path:: Path, agent : & str , extra : & [& str ]) -> Shim {
25
let mut cmd = tokio:: process:: Command:: from( hermetic ( d) ) ;
26
cmd.args ( [ " mcp" , " --agent" , agent] )
27
.args ( extra)
28
.stdin ( Stdio:: piped( ) )
29
.stdout ( Stdio:: piped( ) )
30
.stderr ( Stdio:: piped( ) )
31
.kill_on_drop ( true ) ;
32
let mut child = cmd.spawn ( ) .unwrap ( ) ;
33
let stdin = child.stdin.take ( ) .unwrap ( ) ;
34
let stdout = BufReader:: new( child.stdout.take ( ) .unwrap ( ) ) .lines ( ) ;
62d35f1d Andy McCall 2 days ago ↶
35
Shim { _child: child, stdin, stdout }
eb675dfa Andy McCall 2 days ago ↶
36
}
37
async fn send ( & mut self , v : Value) {
38
self .stdin.write_all ( format! ( " {v} \n " ) .as_bytes ( ) ) .await.unwrap ( ) ;
39
self .stdin.flush ( ) .await.unwrap ( ) ;
40
}
41
async fn recv ( & mut self ) -> Value {
42
let line = tokio:: time:: timeout( Duration:: from_secs( 20 ) , self .stdout.next_line ( ) )
43
.await
44
.expect ( " an answer within 20 s" )
45
.unwrap ( )
46
.expect ( " a line" ) ;
47
serde_json:: from_str( & line) .unwrap_or_else ( | e | panic! ( " stdout carries only JSON-RPC, got {line:?}: {e}" ) )
48
}
49
}
50
51
fn call ( id : u64 , name : & str , args : Value) -> Value {
52
json! ( { " jsonrpc" : " 2.0" , " id" : id, " method" : " tools/call" , " params" : { " name" : name, " arguments" : args } } )
53
}
54
55
# [ tokio ::test ]
56
async fn a_client_speaks_to_spectra_through_the_shim ( ) {
57
let dir = setup ( ) ;
58
let d = dir.path ( ) ;
59
add_agent ( d, " claude-oxygen" , & [ ] ) ;
60
let kimi = add_agent ( d, " kimi-oxygen" , & [ ] ) ;
61
let srv = Serving:: start( d) ;
62
let mut s = Shim:: start( d, " claude-oxygen" , & [ ] ) ;
63
64
s.send ( json! ( { " jsonrpc" : " 2.0" , " id" : 1 , " method" : " initialize" , " params" : { " protocolVersion" : " 2025-06-18" , " capabilities" : { } , " clientInfo" : { " name" : " test" , " version" : " 1" } } } ) ) .await;
65
let init = s.recv ( ) .await;
66
assert_eq! ( init[ " result" ] [ " protocolVersion" ] , " 2025-06-18" ) ;
67
s.send ( json! ( { " jsonrpc" : " 2.0" , " method" : " notifications/initialized" } ) ) .await;
68
s.send ( json! ( { " jsonrpc" : " 2.0" , " id" : 2 , " method" : " tools/list" } ) ) .await;
69
let list = s.recv ( ) .await;
70
assert_eq! ( list[ " id" ] , 2 , " a notification gets no answer" ) ;
71
assert_eq! ( list[ " result" ] [ " tools" ] .as_array ( ) .unwrap ( ) .len ( ) , 11 ) ;
72
s.send ( call ( 3 , " brief" , json! ( { } ) ) ) .await;
73
let brief = s.recv ( ) .await;
74
assert! ( brief[ " result" ] [ " content" ] [ 0 ] [ " text" ] .as_str ( ) .unwrap ( ) .contains ( " You are claude-oxygen · Andy McCall's agent" ) ) ;
75
76
77
s.send ( call ( 10 , " wait" , json! ( { " timeout_s" : 15 } ) ) ) .await;
78
tokio:: time:: sleep( Duration:: from_millis( 200 ) ) .await;
79
s.send ( json! ( { " jsonrpc" : " 2.0" , " id" : 11 , " method" : " ping" } ) ) .await;
80
assert_eq! ( s.recv ( ) .await[ " id" ] , 11 , " ping is not held up by the wait" ) ;
81
let k = Client:: new( & srv.socket, Some ( kimi) ) ;
82
let say = json! ( { " jsonrpc" : " 2.0" , " id" : 1 , " method" : " tools/call" , " params" : { " name" : " say" , " arguments" : { " body" : " @claude-oxygen ready when you are" , " channel" : " general" } } } ) ;
83
assert_eq! ( k.mcp ( " 2025-11-25" , & say) .await.unwrap ( ) .status, 200 ) ;
84
let woke = s.recv ( ) .await;
85
assert_eq! ( woke[ " id" ] , 10 ) ;
86
assert! ( woke[ " result" ] [ " content" ] [ 0 ] [ " text" ] .as_str ( ) .unwrap ( ) .contains ( " ready when you are" ) , " {woke}" ) ;
87
88
drop ( s) ;
89
srv.stop ( ) ;
90
}
91
92
# [ tokio ::test ]
93
async fn the_shim_says_plainly_what_is_wrong ( ) {
94
let dir = setup ( ) ;
95
let d = dir.path ( ) ;
96
add_agent ( d, " claude-oxygen" , & [ ] ) ;
97
add_agent ( d, " kimi-oxygen" , & [ ] ) ;
98
99
100
let mut s = Shim:: start( d, " claude-oxygen" , & [ ] ) ;
101
s.send ( json! ( { " jsonrpc" : " 2.0" , " id" : 1 , " method" : " initialize" , " params" : { " protocolVersion" : " 2025-11-25" } } ) ) .await;
102
let r = s.recv ( ) .await;
103
assert! ( r[ " error" ] [ " message" ] .as_str ( ) .unwrap ( ) .contains ( " spectra serve" ) , " {r}" ) ;
104
drop ( s) ;
105
106
let out = hermetic ( d) .args ( [ " mcp" , " --agent" , " nobody" ] ) .stdin ( Stdio:: null( ) ) .output ( ) .unwrap ( ) ;
107
assert! ( ! out.status.success ( ) & & String :: from_utf8_lossy( & out.stderr) .contains ( " no agent named nobody" ) ) ;
108
assert! ( out.stdout.is_empty ( ) , " errors never go to stdout" ) ;
109
let other = d.join ( " agents/kimi-oxygen" ) ;
110
let out =
111
hermetic ( d) .args ( [ " mcp" , " --agent" , " claude-oxygen" , " --home" , other.to_str ( ) .unwrap ( ) ] ) .stdin ( Stdio:: null( ) ) .output ( ) .unwrap ( ) ;
112
assert! (
113
! out.status.success ( ) & & String :: from_utf8_lossy( & out.stderr) .contains ( " holds kimi-oxygen's key" ) ,
114
" {}" ,
115
String :: from_utf8_lossy( & out.stderr)
116
) ;
117
}