91334b7f Andy McCall 4 days ago ↶
1
2
3
4
5
6
7
8
9
10
11
12
use anyhow:: { bail, Result } ;
13
use oxy_core:: mcp:: { self , arg_str, arg_strings, ServerInfo, Tool, ToolOutput, Tools} ;
14
use serde_json:: { json, Value} ;
15
use std:: path:: { Path, PathBuf} ;
16
use std:: process:: { Command, Stdio} ;
17
18
19
const MAX_TEXT : usize = 256 * 1024 ;
20
21
type Build = fn ( & Value, bool ) -> Result< Vec< String> , String> ;
22
23
struct Spec {
24
name : & 'static str ,
25
description : & 'static str ,
26
schema : fn ( ) -> Value ,
27
build : Build,
28
json : bool ,
29
read_only : bool ,
30
destructive : bool ,
31
}
32
33
fn obj ( props : Value, required : & [& str ]) -> Value {
34
json! ( { " type" : " object" , " properties" : props, " required" : required, " additionalProperties" : false } )
35
}
36
37
fn none ( ) -> Value {
38
obj ( json! ( { } ) , & [ ] )
39
}
40
41
42
43
fn opt ( flag : & str , value : & str ) -> String {
44
format! ( " {flag} ={value} " )
45
}
46
47
48
fn positional ( out : & mut Vec< String> , values : Vec< String> ) {
49
if ! values.is_empty ( ) {
50
out.push ( " --" .into ( ) ) ;
51
out.extend ( values) ;
52
}
53
}
54
55
fn required < 'a > ( args : & 'a Value, key : & str ) -> Result< & 'a str , String> {
56
arg_str ( args, key)
57
.filter ( | s | ! s.trim ( ) .is_empty ( ) )
58
.ok_or_else ( | | format! ( " {key} is required" ) )
59
}
60
61
62
63
fn paths_or_all ( args : & Value) -> Result< Vec< String> , String> {
64
let paths = arg_strings ( args, " paths" ) ? ;
65
let all = args.get ( " all" ) .and_then ( Value:: as_bool) .unwrap_or ( false ) ;
66
match ( paths.is_empty ( ) , all) {
67
( false , false ) => Ok ( paths) ,
68
( true , true ) => Ok ( vec! [ ] ) ,
69
( false , true ) => Err ( " give paths or all: true, not both" .into ( ) ) ,
70
( true , false ) => {
71
Err ( " give the paths to include, or all: true for every changed path" .into ( ) )
72
}
73
}
74
}
75
76
fn limit ( args : & Value, default : u64 ) -> String {
77
args.get ( " limit" )
78
.and_then ( Value:: as_u64)
79
.unwrap_or ( default)
80
.clamp ( 1 , 1000 )
81
.to_string ( )
82
}
83
84
fn specs ( ) -> Vec< Spec> {
85
vec! [
86
Spec {
87
name: " status" ,
88
description: " The working copy: the bookmark, changed paths, and every conflict with its kind and the changes behind it." ,
89
schema: none,
90
build: | _, _| Ok ( vec! [ " status" .into ( ) , " --json" .into ( ) ] ) ,
91
json: true ,
92
read_only: true ,
93
destructive: false ,
94
} ,
95
Spec {
96
name: " diff" ,
97
description: " Uncommitted edits in the working copy, or one change's edits when `change` is given (a ChangeId or #hash prefix)." ,
98
schema: | | obj ( json! ( { " change" : { " type" : " string" } } ) , & [ ] ) ,
99
build: | a , _| {
100
let mut v = vec! [ " diff" .into ( ) , " --json" .into ( ) ] ;
101
positional ( & mut v, arg_str ( a, " change" ) .map ( | c | vec! [ c.to_string ( ) ] ) .unwrap_or_default ( ) ) ;
102
Ok ( v)
103
} ,
104
json: true ,
105
read_only: true ,
106
destructive: false ,
107
} ,
108
Spec {
109
name: " log" ,
110
description: " Changes, newest first: a revset (`main`, `@`, …) or the current bookmark, optionally only those touching `path`; `limit` defaults to 20." ,
111
schema: | | obj ( json! ( { " revset" : { " type" : " string" } , " path" : { " type" : " string" } , " limit" : { " type" : " integer" , " minimum" : 1 } } ) , & [ ] ) ,
112
build: | a , _| {
113
let mut v = vec! [ " log" .into ( ) , " --json" .into ( ) , opt ( " --limit" , & limit ( a, 20 ) ) ] ;
114
if let Some ( r) = arg_str ( a, " revset" ) {
115
v.push ( opt ( " --revset" , r) ) ;
116
}
117
if let Some ( p) = arg_str ( a, " path" ) {
118
v.push ( opt ( " --path" , p) ) ;
119
}
120
Ok ( v)
121
} ,
122
json: true ,
123
read_only: true ,
124
destructive: false ,
125
} ,
126
Spec {
127
name: " show" ,
128
description: " One change: its message, author, signatures and edits (`stat: true` for files and line counts only). `change` defaults to @, the working copy." ,
129
schema: | | obj ( json! ( { " change" : { " type" : " string" } , " stat" : { " type" : " boolean" } } ) , & [ ] ) ,
130
build: | a , _| {
131
let mut v = vec! [ " show" .into ( ) , " --json" .into ( ) ] ;
132
if a.get ( " stat" ) .and_then ( Value:: as_bool) .unwrap_or ( false ) {
133
v.push ( " --stat" .into ( ) ) ;
134
}
135
positional ( & mut v, vec! [ arg_str ( a, " change" ) .unwrap_or ( " @" ) .to_string ( ) ] ) ;
136
Ok ( v)
137
} ,
138
json: true ,
139
read_only: true ,
140
destructive: false ,
141
} ,
142
Spec {
143
name: " cat" ,
144
description: " A file's content as a bookmark, tag or change has it (`change`), or as committed on the working copy's base." ,
145
schema: | | obj ( json! ( { " path" : { " type" : " string" } , " change" : { " type" : " string" } } ) , & [ " path" ] ) ,
146
build: | a , _| {
147
let mut v = vec! [ " cat" .into ( ) ] ;
148
if let Some ( c) = arg_str ( a, " change" ) {
149
v.push ( opt ( " --change" , c) ) ;
150
}
151
positional ( & mut v, vec! [ required ( a, " path" ) ? .to_string ( ) ] ) ;
152
Ok ( v)
153
} ,
154
json: false ,
155
read_only: true ,
156
destructive: false ,
157
} ,
158
Spec {
159
name: " blame" ,
160
description: " Which change wrote each line of a file, with its line atoms." ,
161
schema: | | obj ( json! ( { " path" : { " type" : " string" } } ) , & [ " path" ] ) ,
162
build: | a , _| {
163
let mut v = vec! [ " blame" .into ( ) , " --json" .into ( ) ] ;
164
positional ( & mut v, vec! [ required ( a, " path" ) ? .to_string ( ) ] ) ;
165
Ok ( v)
166
} ,
167
json: true ,
168
read_only: true ,
169
destructive: false ,
170
} ,
171
Spec {
172
name: " bookmarks" ,
173
description: " Bookmarks with their heads and how far each is ahead of or behind its remote; `all: true` adds remotes' bookmarks." ,
174
schema: | | obj ( json! ( { " all" : { " type" : " boolean" } } ) , & [ ] ) ,
175
build: | a , _| {
176
let mut v = vec! [ " bookmark" .into ( ) , " list" .into ( ) , " --json" .into ( ) ] ;
177
if a.get ( " all" ) .and_then ( Value:: as_bool) .unwrap_or ( false ) {
178
v.push ( " --all" .into ( ) ) ;
179
}
180
Ok ( v)
181
} ,
182
json: true ,
183
read_only: true ,
184
destructive: false ,
185
} ,
186
Spec {
187
name: " tags" ,
188
description: " Tags with their states, messages and signers." ,
189
schema: none,
190
build: | _, _| Ok ( vec! [ " tag" .into ( ) , " list" .into ( ) , " --json" .into ( ) ] ) ,
191
json: true ,
192
read_only: true ,
193
destructive: false ,
194
} ,
195
Spec {
196
name: " conflicts" ,
197
description: " Which conflicts of the working copy's base the current files resolve, and which remain." ,
198
schema: none,
199
build: | _, _| Ok ( vec! [ " resolve" .into ( ) , " --json" .into ( ) ] ) ,
200
json: true ,
201
read_only: true ,
202
destructive: false ,
203
} ,
204
Spec {
205
name: " op_log" ,
206
description: " The operation log, newest first (what undo would take back); `limit` defaults to 20." ,
207
schema: | | obj ( json! ( { " limit" : { " type" : " integer" , " minimum" : 1 } } ) , & [ ] ) ,
208
build: | a , _| Ok ( vec! [ " op" .into ( ) , " log" .into ( ) , " --json" .into ( ) , opt ( " --limit" , & limit ( a, 20 ) ) ] ) ,
209
json: true ,
210
read_only: true ,
211
destructive: false ,
212
} ,
213
Spec {
214
name: " identity" ,
215
description: " Who changes are signed as here: the author, the key, delegates and the signer (an agent home signs keyless, via the agent)." ,
216
schema: none,
217
build: | _, _| Ok ( vec! [ " identity" .into ( ) , " show" .into ( ) ] ) ,
218
json: false ,
219
read_only: true ,
220
destructive: false ,
221
} ,
222
Spec {
223
name: " commit" ,
224
description: " Record a change with a message: only `paths`, or every changed path with `all: true`. Signed as this server's identity." ,
225
schema: | | obj ( json! ( { " message" : { " type" : " string" } , " paths" : { " type" : " array" , " items" : { " type" : " string" } } , " all" : { " type" : " boolean" } } ) , & [ " message" ] ) ,
226
build: | a , _| {
227
let mut v = vec! [ " commit" .into ( ) , opt ( " --message" , required ( a, " message" ) ? ) ] ;
228
positional ( & mut v, paths_or_all ( a) ? ) ;
229
Ok ( v)
230
} ,
231
json: false ,
232
read_only: false ,
233
destructive: false ,
234
} ,
235
Spec {
236
name: " amend" ,
237
description: " Fold edits into the change the working copy sits on: only `paths`, or all with `all: true`; `message` replaces its message too." ,
238
schema: | | obj ( json! ( { " message" : { " type" : " string" } , " paths" : { " type" : " array" , " items" : { " type" : " string" } } , " all" : { " type" : " boolean" } } ) , & [ ] ) ,
239
build: | a , _| {
240
let mut v = vec! [ " amend" .into ( ) ] ;
241
if let Some ( m) = arg_str ( a, " message" ) {
242
v.push ( opt ( " --message" , m) ) ;
243
}
244
positional ( & mut v, paths_or_all ( a) ? ) ;
245
Ok ( v)
246
} ,
247
json: false ,
248
read_only: false ,
249
destructive: false ,
250
} ,
251
Spec {
252
name: " describe" ,
253
description: " Change a head's message (`change` defaults to the one the working copy sits on)." ,
254
schema: | | obj ( json! ( { " message" : { " type" : " string" } , " change" : { " type" : " string" } } ) , & [ " message" ] ) ,
255
build: | a , _| {
256
let mut v = vec! [ " describe" .into ( ) , " --json" .into ( ) , opt ( " --message" , required ( a, " message" ) ? ) ] ;
257
positional ( & mut v, arg_str ( a, " change" ) .map ( | c | vec! [ c.to_string ( ) ] ) .unwrap_or_default ( ) ) ;
258
Ok ( v)
259
} ,
260
json: true ,
261
read_only: false ,
262
destructive: false ,
263
} ,
264
Spec {
265
name: " fetch" ,
266
description: " Fetch from a remote (default origin) without moving local bookmarks." ,
267
schema: | | obj ( json! ( { " remote" : { " type" : " string" } } ) , & [ ] ) ,
268
build: | a , _| remote_cmd ( " fetch" , a) ,
269
json: true ,
270
read_only: false ,
271
destructive: false ,
272
} ,
273
Spec {
274
name: " pull" ,
275
description: " Fetch from a remote (default origin) and bring the current bookmark and working copy up to date." ,
276
schema: | | obj ( json! ( { " remote" : { " type" : " string" } } ) , & [ ] ) ,
277
build: | a , _| remote_cmd ( " pull" , a) ,
278
json: true ,
279
read_only: false ,
280
destructive: false ,
281
} ,
282
Spec {
283
name: " push" ,
284
description: " Push the current bookmark (or `bookmark`) to a remote (default origin). Changes are sealed at push as this server's identity. `force` (drop heads the remote has) needs the server started with --allow-destructive." ,
285
schema: | | obj ( json! ( { " remote" : { " type" : " string" } , " bookmark" : { " type" : " string" } , " force" : { " type" : " boolean" } } ) , & [ ] ) ,
286
build: | a , destructive | {
287
let mut v = vec! [ " push" .to_string ( ) , " --json" .into ( ) ] ;
288
if let Some ( b) = arg_str ( a, " bookmark" ) {
289
v.push ( opt ( " --bookmark" , b) ) ;
290
}
291
if a.get ( " force" ) .and_then ( Value:: as_bool) .unwrap_or ( false ) {
292
if ! destructive {
293
return Err ( " a forced push can drop the remote's heads; start oxy mcp with --allow-destructive to allow it" .into ( ) ) ;
294
}
295
v.push ( " --force" .into ( ) ) ;
296
}
297
positional ( & mut v, arg_str ( a, " remote" ) .map ( | r | vec! [ r.to_string ( ) ] ) .unwrap_or_default ( ) ) ;
298
Ok ( v)
299
} ,
300
json: true ,
301
read_only: false ,
302
destructive: false ,
303
} ,
304
Spec {
305
name: " tlog_verify" ,
306
description: " Check the hub's transparency log: that it shows everyone the same history, with this repository's entries and each witness's cosignature." ,
307
schema: none,
308
build: | _, _| Ok ( vec! [ " tlog" .into ( ) , " verify" .into ( ) , " --json" .into ( ) ] ) ,
309
json: true ,
310
read_only: true ,
311
destructive: false ,
312
} ,
313
Spec {
314
name: " undo" ,
315
description: " Take back the last operation (see op_log). Offered only when the server was started with --allow-destructive." ,
316
schema: none,
317
build: | _, _| Ok ( vec! [ " undo" .into ( ) ] ) ,
318
json: false ,
319
read_only: false ,
320
destructive: true ,
321
} ,
322
]
323
}
324
325
fn remote_cmd ( command : & str , a : & Value) -> Result< Vec< String> , String> {
326
let mut v = vec! [ command.to_string ( ) , " --json" .into ( ) ] ;
327
positional (
328
& mut v,
329
arg_str ( a, " remote" )
330
.map ( | r | vec! [ r.to_string ( ) ] )
331
.unwrap_or_default ( ) ,
332
) ;
333
Ok ( v)
334
}
335
336
struct OxyTools {
337
exe : PathBuf,
338
dir : PathBuf,
339
allow_destructive : bool ,
340
}
341
342
impl OxyTools {
343
fn offered ( & self ) -> impl Iterator< Item = Spec> + ' _ {
344
specs ( )
345
.into_iter ( )
346
.filter ( | s | self .allow_destructive | | ! s.destructive)
347
}
348
349
fn run ( & self , spec : & Spec, argv : Vec< String> ) -> ToolOutput {
350
let out = Command:: new( & self .exe)
351
.arg ( " --plain" )
352
.args ( & argv)
353
.current_dir ( & self .dir)
354
.stdin ( Stdio:: null( ) )
355
.output ( ) ;
356
let out = match out {
357
Ok ( o) => o,
358
Err ( e) => return ToolOutput:: error( format! ( " could not run oxy: {e} " ) ) ,
359
} ;
360
let stdout = String :: from_utf8_lossy( & out.stdout) .into_owned ( ) ;
361
let stderr = String :: from_utf8_lossy( & out.stderr) .into_owned ( ) ;
362
if ! out.status.success ( ) {
363
let why = if stderr.trim ( ) .is_empty ( ) {
364
stdout
365
} else {
366
stderr
367
} ;
368
return ToolOutput:: error( format! ( " oxy {} failed: {} " , argv.join ( " " ) , why.trim ( ) ) ) ;
369
}
370
let mut result = ToolOutput:: ok( stdout.clone ( ) ) ;
371
if spec.json {
372
result.structured = serde_json:: from_str( & stdout) .ok ( ) ;
373
} else if ! stderr.trim ( ) .is_empty ( ) & & stdout.trim ( ) .is_empty ( ) {
374
375
result.text = stderr;
376
}
377
if result.text.len ( ) > MAX_TEXT {
378
let mut cut = MAX_TEXT ;
379
while ! result.text.is_char_boundary ( cut) {
380
cut - = 1 ;
381
}
382
result.text.truncate ( cut) ;
383
result.text.push_str (
384
" \n … (cut at 256 KiB: narrow the request, e.g. a smaller limit or one path)" ,
385
) ;
386
result.structured = None ;
387
}
388
result
389
}
390
}
391
392
impl Tools for OxyTools {
393
fn list ( & self ) -> Vec< Tool> {
394
self .offered ( )
395
.map ( | s | Tool {
396
name: s.name.into ( ) ,
397
description: s.description.into ( ) ,
398
input_schema: ( s.schema) ( ) ,
399
read_only: s.read_only,
400
destructive: s.destructive,
401
} )
402
.collect ( )
403
}
404
405
fn call ( & self , name : & str , args : & Value) -> Option< ToolOutput> {
406
let spec = self .offered ( ) .find ( | s | s.name = = name ) ? ;
407
Some ( match ( spec.build) ( args, self .allow_destructive) {
408
Ok ( argv) => self .run ( & spec, argv) ,
409
Err ( why) => ToolOutput:: error( why) ,
410
} )
411
}
412
}
413
414
415
416
fn signer_line ( exe : & Path, dir : & Path) -> String {
417
let out = Command:: new( exe)
418
.args ( [ " --plain" , " identity" , " show" ] )
419
.current_dir ( dir)
420
.stdin ( Stdio:: null( ) )
421
.output ( ) ;
422
match out {
423
Ok ( o) if o.status.success ( ) => {
424
let text = String :: from_utf8_lossy( & o.stdout) ;
425
let who = text.lines ( ) .next ( ) .unwrap_or ( " " ) .trim_start_matches ( [ ' #' , ' ▍' , ' ' ] ) .trim ( ) .to_string ( ) ;
426
let signing = text.lines ( ) .find ( | l | l.trim_start ( ) .starts_with ( " signing" ) ) .map ( | l | l.trim ( ) .to_string ( ) ) ;
427
match signing {
428
Some ( s) => format! ( " {who} ; {s} " ) ,
429
None => who,
430
}
431
}
432
_ => " no identity is configured: commits will be refused until one is (oxy identity create, or an agent home with OXY_HOME)" .into ( ) ,
433
}
434
}
435
436
pub fn serve ( dir : Option< PathBuf> , allow_destructive : bool ) -> Result< i32 > {
437
let dir = match dir {
438
Some ( d) => d,
439
None => std:: env:: current_dir( ) ? ,
440
} ;
441
if ! dir.join ( oxy_core:: repo:: OXY_DIR ) .is_dir ( ) {
442
bail! ( " {} is not the root of an Oxygen repository (no {}): start oxy mcp in a checkout's root, or name it" , dir.display ( ) , oxy_core:: repo:: OXY_DIR ) ;
443
}
444
let exe = std:: env:: current_exe( ) ? ;
445
let signer = signer_line ( & exe, & dir) ;
446
let info = ServerInfo {
447
name: " oxy" .into ( ) ,
448
version: env! ( " CARGO_PKG_VERSION" ) .into ( ) ,
449
instructions: format! (
450
" Oxygen version control for the repository at {} . Changes are signed as: {signer} . Prefer these tools to running oxy in a shell. \
451
commit and amend take explicit paths (or all: true). {} " ,
452
dir.display ( ) ,
453
if allow_destructive { " Destructive tools (undo, forced push) are enabled: use them only when asked." } else { " Destructive tools (undo, forced push) are off." }
454
) ,
455
} ;
456
let tools = OxyTools {
457
exe,
458
dir,
459
allow_destructive,
460
} ;
461
let stdin = std:: io:: stdin( ) ;
462
mcp:: serve( & info, & tools, stdin.lock ( ) , std:: io:: stdout( ) .lock ( ) ) ? ;
463
Ok ( 0 )
464
}
465
466
# [ cfg ( test ) ]
467
mod tests {
468
use super :: * ;
469
470
fn build ( name : & str , args : Value, destructive : bool ) -> Result< Vec< String> , String> {
471
let spec = specs ( ) .into_iter ( ) .find ( | s | s.name = = name ) .unwrap ( ) ;
472
( spec.build) ( & args, destructive)
473
}
474
475
# [ test ]
476
fn values_never_read_as_flags ( ) {
477
assert_eq! (
478
build (
479
" commit" ,
480
json! ( { " message" : " -m oops" , " paths" : [ " -rf" ] } ) ,
481
false
482
)
483
.unwrap ( ) ,
484
[ " commit" , " --message=-m oops" , " --" , " -rf" ]
485
) ;
486
assert_eq! (
487
build ( " cat" , json! ( { " path" : " --help" , " change" : " main" } ) , false ) .unwrap ( ) ,
488
[ " cat" , " --change=main" , " --" , " --help" ]
489
) ;
490
assert_eq! (
491
build ( " log" , json! ( { " limit" : 5000 } ) , false ) .unwrap ( ) ,
492
[ " log" , " --json" , " --limit=1000" ] ,
493
" limit is capped"
494
) ;
495
}
496
497
# [ test ]
498
fn commits_name_what_goes_in ( ) {
499
assert! ( build ( " commit" , json! ( { " message" : " x" } ) , false )
500
.unwrap_err ( )
501
.contains ( " all: true" ) ) ;
502
assert! ( build (
503
" commit" ,
504
json! ( { " message" : " x" , " paths" : [ " a" ] , " all" : true } ) ,
505
false
506
)
507
.is_err ( ) ) ;
508
assert_eq! (
509
build ( " commit" , json! ( { " message" : " x" , " all" : true } ) , false ) .unwrap ( ) ,
510
[ " commit" , " --message=x" ]
511
) ;
512
assert! ( build ( " commit" , json! ( { " message" : " " , " all" : true } ) , false )
513
.unwrap_err ( )
514
.contains ( " message" ) ) ;
515
}
516
517
# [ test ]
518
fn destructive_tools_need_the_flag ( ) {
519
let off = OxyTools {
520
exe: " oxy" .into ( ) ,
521
dir: " ." .into ( ) ,
522
allow_destructive: false ,
523
} ;
524
let on = OxyTools {
525
exe: " oxy" .into ( ) ,
526
dir: " ." .into ( ) ,
527
allow_destructive: true ,
528
} ;
529
assert! ( ! off.list ( ) .iter ( ) .any ( | t | t.name = = " undo" ) ) ;
530
assert! (
531
off.call ( " undo" , & json! ( { } ) ) .is_none ( ) ,
532
" an unoffered tool is unknown"
533
) ;
534
assert! ( on.list ( ) .iter ( ) .any ( | t | t.name = = " undo" & & t.destructive ) ) ;
535
assert! ( build ( " push" , json! ( { " force" : true } ) , false )
536
.unwrap_err ( )
537
.contains ( " --allow-destructive" ) ) ;
538
assert_eq! (
539
build ( " push" , json! ( { " force" : true , " remote" : " earth" } ) , true ) .unwrap ( ) ,
540
[ " push" , " --json" , " --force" , " --" , " earth" ]
541
) ;
542
}
543
}