bevy_fairy 0.12.0

An agentic debugger for Bevy games: a Bevy plugin that exposes the game's state over BRP, and an MCP server that lets a coding agent read and drive it
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
/*
 * bevy-fairy - Centralized Tool Definitions
 * Copyright (C) 2025 ladvien
 *
 * Licensed under either of MIT (LICENSE-MIT) or Apache-2.0 (LICENSE-APACHE), at your option.
 *
 * Relicensed from GPL-3.0 when this crate was adopted into Ladvien/foundation_vs_slop: a GPL
 * crate in the Bevy ecosystem cannot be adopted, and being adoptable is why it is published.
 */

use rmcp::{model::*, tool, tool_router, handler::server::{ServerHandler, router::tool::ToolRouter, tool::Parameters}, schemars, Error as McpError};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{sync::Arc, future::Future};
use tokio::sync::RwLock;
use tracing::{error, debug};
use schemars::JsonSchema;

use crate::server::brp_client::BrpClient;
use crate::server::tools::{observe, experiment, guide, hypothesis, anomaly};
use crate::server::experiment_system::Action;
use crate::server::brp::{BrpPayload, BrpRequest};
use crate::server::watch_fields;
use base64::Engine;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

// Parameter structures for tools
//
// **Every one of these must be named in a `#[tool]` signature, not deserialized from
// `Parameters<Value>` inside the body.** `schemars` renders `serde_json::Value` as
// `{"$schema": …, "title": "AnyValue"}` — a schema with no `type` and no `properties` — and an MCP
// client rejects the whole tool list with `expected "object" (at tools.N.inputSchema.type)`. The
// tools were unreachable in Claude Code for exactly that reason.

#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ObserveRequest {
    /// A phrase in one of the shapes the parser accepts; a bare word such as "score" or "crate" is
    /// refused. Shapes, each a complete example: `list all entities`, `list components`,
    /// `show entity 4294967298`, `find entities with component bevy_ecs::name::Name`,
    /// `find entities without component bevy_ecs::name::Name`, `find 5 entities with component bevy_ecs::name::Name`.
    /// Component names are full type paths, and a path may hold digits and generic arguments: `find entities with component bevy_mesh::components::Mesh3d`, `find entities with component bevy_time::time::Time<bevy_time::real::Real>`. In a list only the commas outside angle brackets separate components. A name the game does not register is an error that lists the registered paths ending in it. The metadata carries entity_count (component_type_count for the list-components query) and read_at; find N entities returns at most N rows and adds limit and matched, the number that matched before the cut. For any other read, such as one
    /// resource, use `brp`.
    pub query: String,
    /// Compare this read with the previous `diff: true` read of the same query in this server
    /// session. The first such read is the baseline and says so, and so is the first read after a
    /// game restart (the earlier read was of another game, whose entity ids are not comparable).
    /// With find N entities the diff compares every matched entity, not only the N rows returned.
    #[serde(default)]
    pub diff: bool,
}

/// What `guide` accepts. One tool, four verbs, because they are four things you do to one script
/// and splitting them would make an agent choose a tool before it knows what it wants.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct GuideRequest {
    /// The steps to post, replacing whatever is up. Each step is an object:
    /// `label` (required), `goal`, `do` (array of 2-4 short imperatives), `checkpoint`
    /// (a condition name the APP registered, or null when only a person can judge it),
    /// and `recovery` (what to do when it does not work).
    #[serde(default)]
    pub steps: Option<Vec<Value>>,
    /// Return the transcript and where the person is, changing nothing. Safe to call at any time,
    /// and the way to find out whether the current step is waiting on them.
    #[serde(default)]
    pub read: bool,
    /// Move past the current step WITHOUT its condition passing. This is how a step only a person
    /// can judge gets advanced -- after they have told you, never before.
    #[serde(default)]
    pub skip: bool,
    /// Take the script down. The transcript survives, because the transcript is the result.
    #[serde(default)]
    pub clear: bool,
    /// Show or hide the card without discarding the script.
    #[serde(default)]
    pub visible: Option<bool>,
}

/// `experiment`: run actions against the game. The fields here are the ones the handler reads.
/// `action` picks the operation, `actions` is what it acts on. Before this struct was written the
/// tool advertised `type`/`params`/`duration` and the handler read `action`/`actions`, so every
/// call ran the default branch and the caller's intent was dropped.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ExperimentRequest {
    /// The operation to run. One of `execute`, `undo`, `redo`, `history`, `clear_history`.
    pub action: String,
    /// The actions to carry out. Required by `execute`; ignored by the other operations.
    #[serde(default)]
    pub actions: Vec<Action>,
}

/// How `hypothesis` varies a test between iterations: the strategies the handler runs.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum VariationType {
    None,
    Random,
    Grid,
    Boundary,
    Fuzz,
}

/// `hypothesis`: state a claim and check it against the game. The handler reads `description` and
/// `success_condition`; the tool used to send `hypothesis`/`confidence`/`context`, so the handler
/// fell through to its default and then failed on a `description` the schema never advertised.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct HypothesisRequest {
    /// The operation to run. One of `test`, `quick_test` (10 iterations, 10 s; refuses `iterations`
    /// and `timeout`, which `test` takes), `stress_test` (fuzz variation, 1000 iterations unless
    /// `iterations` or `variation_type` say otherwise), `validate` (checks the claim parses; runs nothing).
    pub action: String,
    /// What the claim is, in words. `test` and `quick_test` require it.
    #[serde(default)]
    pub description: Option<String>,
    /// The condition that decides whether the claim held: a string such as
    /// entity_exists:game::Crate, or an object such as {"type": "entity_exists",
    /// "component_types": ["game::Crate"], "count": 6}. `test` and `quick_test` require it.
    #[serde(default)]
    pub success_condition: Option<Value>,
    /// The actions to run before the condition is read, on every iteration. After the condition is
    /// read the server reverses what they spawned or modified, so each iteration starts from the
    /// game as it was found; a delete cannot be put back, and the reply's game_state says so.
    #[serde(default)]
    pub actions: Vec<Action>,
    /// How to vary the test between iterations. Default `none`. A variation changes the numbers,
    /// strings and booleans inside `actions`, at any depth of a component's value, and never
    /// which entity an action targets; with no actions, none it can change, or no values to try
    /// the call is refused. `random` varies within `numeric_min`..`numeric_max`, `string_pool`
    /// and `bool_probability`; `grid` walks `numeric_values`, `string_values` and `bool_values`,
    /// for the kinds of value the actions write that it was given values for; `boundary` tries
    /// edge values chosen by the `include_*` flags; `fuzz` mutates the actions up to
    /// `max_mutations` times, each with probability `mutation_probability`. A number the
    /// `actions` write as an integer (5, not 5.0) is rounded by `random` and `fuzz`; `grid` and
    /// `boundary` write their values as given, a whole number as an integer and any other as a
    /// float, which an integer field refuses.
    #[serde(default)]
    pub variation_type: Option<VariationType>,
    /// For `random`: the low end of the numeric range to vary over. Default 0.
    #[serde(default)]
    pub numeric_min: Option<f64>,
    /// For `random`: the high end of the numeric range to vary over. Default 100.
    #[serde(default)]
    pub numeric_max: Option<f64>,
    /// For `random`: the strings to vary over. Default none.
    #[serde(default)]
    pub string_pool: Option<Vec<String>>,
    /// For `random`: the probability a varied boolean is true. Default 0.5.
    #[serde(default)]
    pub bool_probability: Option<f64>,
    /// For `grid`: the numbers to try. Default 0, 50, 100.
    #[serde(default)]
    pub numeric_values: Option<Vec<f64>>,
    /// For `grid`: the strings to try. Default none: strings are left as they are.
    #[serde(default)]
    pub string_values: Option<Vec<String>>,
    /// For `grid`: the booleans to try. Default true and false.
    #[serde(default)]
    pub bool_values: Option<Vec<bool>>,
    /// For `boundary`: try zero. Default true.
    #[serde(default)]
    pub include_zero: Option<bool>,
    /// For `boundary`: try negative values. Default true.
    #[serde(default)]
    pub include_negative: Option<bool>,
    /// For `boundary`: try the largest values. Default true.
    #[serde(default)]
    pub include_max: Option<bool>,
    /// For `boundary`: try empty strings. Default true.
    #[serde(default)]
    pub include_empty: Option<bool>,
    /// For `fuzz`: the most mutations applied to one run. Default 3.
    #[serde(default)]
    pub max_mutations: Option<u64>,
    /// For `fuzz`: the probability each mutation applies. Default 0.5.
    #[serde(default)]
    pub mutation_probability: Option<f64>,
    /// How many times to run the test. With no variation the test runs once, and `grid` and
    /// `boundary` run at most as many times as they have values to try.
    #[serde(default)]
    pub iterations: Option<u64>,
    /// How long the whole run may take, in seconds. Default 60; `quick_test` fixes it at 10 and refuses one. When
    /// it passes, the run stops between iterations (one already started finishes) and the
    /// reply's stopped_early says so; iterations that never ran count against CONFIRMED. Other
    /// tools are answered between iterations.
    #[serde(default)]
    pub timeout: Option<u64>,
    /// The seed for `random` and `fuzz` variation: the same seed draws the same values.
    #[serde(default)]
    pub seed: Option<u64>,
    /// The share of iterations, above 0 and at most 1, in which the condition must hold for the
    /// claim to count as confirmed. Default 0.95. An iteration that was not evaluated does not count as held.
    #[serde(default)]
    pub threshold: Option<f64>,
}

/// `detect_anomaly`: look for anomalies in the running game. The handler reads the thresholds below;
/// the tool used to send `type`/`sensitivity`/`window`, none of which the handler reads, so every
/// call ran with the default configuration and the caller's intent was dropped.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AnomalyRequest {
    /// The operation to run. One of `detect`, `configure`, `status`.
    pub action: String,
    /// How many samples the window covers. At least `min_samples` and at least 2: a detector keeps this many samples and cannot report before it has `min_samples` of them, so `configure` refuses a smaller window.
    #[serde(default)]
    pub window_size: Option<u64>,
    /// How many standard deviations from its own recent speeds an entity's velocity must be to count as anomalous. The speed is scored against the window it belongs to, so among n samples its score is at most (n - 1) / sqrt(n): 2.85 among 10, 9.9 among 100. A threshold above that bound is never exceeded, and `detect` then reports the detector as not evaluated.
    #[serde(default)]
    pub z_score_threshold: Option<f64>,
    /// The fewest samples needed before a verdict is possible; at most `window_size`.
    #[serde(default)]
    pub min_samples: Option<u64>,
    /// The entities added per `detect` call (measured across the window) above which the entity count counts as growing abnormally.
    #[serde(default)]
    pub entity_growth_threshold: Option<f64>,
    /// The lowest severity to report, from 0 to 1. For a speed or an entity count, severity is how far past its threshold the reading is: 0.5 at twice the threshold. A state inconsistency carries a fixed 0.9, a rule's rating and not a measurement.
    #[serde(default)]
    pub min_severity: Option<f64>,
    /// How many anomalies to report at most.
    #[serde(default)]
    pub limit: Option<u64>,
}

/// A tool that takes no arguments. It is a named, empty struct for the reason given at the top of this file.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NoArgs {}

/// `run`: run one command the game registered. `commands` lists them with their parameters.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RunRequest {
    /// The command name, `<game>/<verb>`, exactly as `commands` lists it.
    pub command: String,
    /// The command's parameters as a JSON object. Omit it for a command that takes none.
    #[serde(default)]
    pub params: Option<Value>,
}

/// `brp`: a raw Bevy Remote Protocol call, for any method the game serves.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct BrpCallRequest {
    /// The BRP method, for example `world.query` or `registry.schema`.
    pub method: String,
    /// The method's params as JSON, omitted for a method that takes none. Shapes, one per line:
    /// - `world.query`: `{"data":{"components":["bevy_ecs::name::Name"]},"filter":{"with":[],"without":[]}}` (full type paths, never short names: take the game's own from `world.list_components`; `filter` is optional)
    /// - `registry.schema`: `{"with_crates":["bevy_transform"],"type_limit":{"with":["Component"]}}` (every field optional and a list; omit one rather than sending null)
    /// - `world.list_components`: no params for every registered component, or `{"entity":4294967296}` for one entity
    /// - `rpc.discover`: no params; lists every method the game serves with its param schema
    #[serde(default)]
    pub params: Option<Value>,
}

/// A rectangle of the captured image, in its pixels.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CaptureRegion {
    pub x: u32,
    pub y: u32,
    pub width: u32,
    pub height: u32,
}

/// `screenshot`: capture the game to a PNG on this machine.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ScreenshotRequest {
    /// File to write on this machine. Default: `$BEVY_FAIRY_CAPTURE_DIR/capture-<unix_ms>.png`,
    /// or `./debug_captures/` when that variable is unset.
    #[serde(default)]
    pub save_to: Option<String>,
    /// `offscreen` (the default) renders the game's own image and leaves out UI. `window` captures
    /// the game window, UI included.
    #[serde(default)]
    pub source: Option<String>,
    /// Crop to this rectangle before saving.
    #[serde(default)]
    pub region: Option<CaptureRegion>,
    /// Magnify the captured image, after the crop, by this factor. Default 1. The result is at most 4096 x 4096 pixels; a zoom that would make it larger is refused, so crop with `region` first.
    #[serde(default)]
    pub zoom: Option<f32>,
    /// Also return the PNG inline, so the agent sees the image as well as the saved file.
    #[serde(default)]
    pub inline: Option<bool>,
}

/// What `scene` does with the commands recorded in this game session.
#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum SceneAction {
    /// Write the recorded commands to `path` as a replayable scene file.
    Save,
    /// Return the recorded commands.
    Show,
    /// Forget the recorded commands.
    Clear,
}

/// `scene`: save, show or clear the recorded commands.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SceneRequest {
    /// `save`, `show` or `clear`.
    pub action: SceneAction,
    /// Required for `save`: the file to write, on this machine.
    #[serde(default)]
    pub path: Option<String>,
}

/// `watch`: sample one BRP method over time, each sample bracketed by the game frames it was read between.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct WatchRequest {
    /// The one-shot BRP method to sample, for example `world.query` or `world.get_resources`.
    /// Names ending in `+watch` are refused.
    pub method: String,
    /// The method's params, exactly as for `brp`.
    #[serde(default)]
    pub params: Option<Value>,
    /// A JSON pointer (RFC 6901) into each result, for example
    /// `/0/components/my_game::Health/current`. Without it each sample carries the whole result,
    /// so use it for anything larger than a few values. A pointer that matches nothing is an error.
    #[serde(default)]
    pub pointer: Option<String>,
    /// How many samples to take, 2 to 100. Default 10.
    #[serde(default)]
    pub samples: Option<u32>,
    /// Milliseconds between samples, 20 to 5000. Default 100. `samples` x `interval_ms` may not
    /// exceed 30000.
    #[serde(default)]
    pub interval_ms: Option<u64>,
}

const WATCH_MAX_TOTAL_MS: u64 = 30_000;
const WATCH_MAX_REPLY_BYTES: usize = 60_000;

// Default value functions

/// Centralized tool schema definitions for better discoverability
#[derive(Clone)]
pub struct BevyDebuggerTools {
    brp_client: Arc<RwLock<BrpClient>>,
    tool_router: ToolRouter<Self>,
}

impl BevyDebuggerTools {
    pub fn new(brp_client: Arc<RwLock<BrpClient>>) -> Self {
        Self {
            brp_client,
            tool_router: Self::tool_router(),
        }
    }

    /// The names of the tools this server registers.
    ///
    /// The instructions and the docs name tools, and a name that is not registered sends an agent
    /// to nothing. This is how a check finds out.
    pub fn tool_names() -> Vec<String> {
        Self::tool_router()
            .list_all()
            .into_iter()
            .map(|tool| tool.name.to_string())
            .collect()
    }
}

/// One entry of `bevy_fairy/commands`, as the game lists it.
#[derive(Debug, Deserialize)]
struct CommandListing {
    commands: Vec<CommandEntry>,
}

#[derive(Debug, Deserialize)]
struct CommandEntry {
    name: String,
    description: String,
    #[serde(default)]
    params: String,
    #[serde(default)]
    example: Value,
}

const STATUS_METHOD: &str = "bevy_fairy/status";
const COMMANDS_METHOD: &str = "bevy_fairy/commands";
const TIME_COMMAND: &str = "bevy_fairy/time";
const SCREENSHOT_METHOD: &str = "bevy_fairy/screenshot";
const CAPTURE_METHOD: &str = "bevy_fairy/capture";
const SCENE_METHOD: &str = "bevy_fairy/scene";
const QUIT_METHOD: &str = "bevy_fairy/quit";

/// The text an agent reads when nothing answers. Kept exact, because agents match on it.
fn no_game_text(url: &str) -> String {
    format!("bevy-fairy: no game answering at {url} — start the game with DebuggerPlugin and RemoteHttpPlugin, or set BEVY_BRP_HOST/BEVY_BRP_PORT")
}

/// What an agent reads when a request did not come back as a reply this server can read, in the
/// order that matters: the game took the request and did not answer in time (it may still act on
/// it, so "no game answering" would send the agent to restart a running game or to send the request
/// again and apply it twice); the game answered with an HTTP failure; the game answered with a body
/// that is not `reply`; and only otherwise that nothing answers. `reply` names what was expected.
fn why_no_reply(client: &BrpClient, method: &str, error: &crate::server::error::Error, reply: &str) -> String {
    use crate::server::error::Error;
    match error {
        Error::Timeout(why) => format!(
            "bevy-fairy: the game at {} did not answer `{method}`: {why}. The request was sent, so the game \
             may still act on it when it next gets to its requests (a long frame, a window that is not being \
             redrawn); check with `status` before sending it again",
            client.url()
        ),
        Error::Brp(why) => format!(
            "bevy-fairy: the game at {} answered `{method}` with a failure, not {reply}: {why}",
            client.url()
        ),
        // The client marks itself connected once the game has answered with a success status; a
        // body it then fails to read is still an answer, and "no game answering" would send the
        // agent off to restart a game that is running.
        _ if client.is_connected() => format!(
            "bevy-fairy: the game at {} answered `{method}`, but not with {reply} this server can read: {error}",
            client.url()
        ),
        _ => {
            debug!("BRP call {method} did not reach the game: {error}");
            no_game_text(client.url())
        }
    }
}

fn tool_ok(text: impl Into<String>) -> Result<CallToolResult, McpError> {
    Ok(CallToolResult::success(vec![Content::text(text.into())]))
}

fn tool_err(text: impl Into<String>) -> Result<CallToolResult, McpError> {
    Ok(CallToolResult::error(vec![Content::text(text.into())]))
}

/// A request as the handler's argument object. Serialising the request struct, not listing its
/// fields by hand, means a field added to the schema reaches the handler; a hand-written list was
/// the second place a field could be added and missed (B-024).
fn request_args<T: Serialize>(req: &T) -> Result<Value, Result<CallToolResult, McpError>> {
    serde_json::to_value(req).map_err(|e| tool_err(format!("the request could not be encoded: {e}")))
}

/// A tool handler's answer as a tool result. The handlers report a refusal -- an unknown action,
/// a query they cannot parse, a game that said no -- as `Ok` with a top-level `"error"` key. That
/// is a failed call, so it goes back with `isError` set: a client that branches on `isError`
/// would otherwise take "Query parsing failed" for an answer.
fn handler_result(result: Value) -> Result<CallToolResult, McpError> {
    if result.get("error").is_some_and(|error| !error.is_null()) {
        tool_err(result.to_string())
    } else {
        tool_ok(result.to_string())
    }
}

fn command_block(entry: &CommandEntry) -> String {
    let params = if entry.params.is_empty() { "(none)" } else { entry.params.as_str() };
    format!("{} — {}\n  params: {}\n  example: {}", entry.name, entry.description, params, entry.example)
}

/// Name the closest registered command, and list them all, when `asked` is not one of them.
fn unknown_command_text(asked: &str, entries: &[CommandEntry]) -> String {
    let known: Vec<&str> = entries.iter().map(|entry| entry.name.as_str()).collect();
    let closest = known
        .iter()
        .copied()
        .max_by(|a, b| strsim::jaro_winkler(asked, a).total_cmp(&strsim::jaro_winkler(asked, b)));
    match closest {
        Some(closest) => format!("unknown command `{asked}`; did you mean `{closest}`? known: {}", known.join(", ")),
        None => format!("unknown command `{asked}`; the game has registered no commands"),
    }
}

fn write_file_creating_dirs(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
    if let Some(parent) = path.parent().filter(|parent| !parent.as_os_str().is_empty()) {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(path, bytes)
}

/// `$BEVY_FAIRY_CAPTURE_DIR/capture-<unix_ms>.png`, or under `./debug_captures` when unset.
fn default_capture_path() -> PathBuf {
    let dir = std::env::var_os("BEVY_FAIRY_CAPTURE_DIR")
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("debug_captures"));
    let unix_ms = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or_default();
    dir.join(format!("capture-{unix_ms}.png"))
}

fn default_burst_dir() -> PathBuf {
    let dir = std::env::var_os("BEVY_FAIRY_CAPTURE_DIR")
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("debug_captures"));
    let unix_ms = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or_default();
    dir.join(format!("burst-{unix_ms}"))
}

fn truncate_for_error(value: &Value) -> String {
    let text = value.to_string();
    if text.len() <= 300 {
        text
    } else {
        format!("{}…", text.chars().take(300).collect::<String>())
    }
}

/// `burst`: a short sequence of captures as one labelled contact sheet.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct BurstRequest {
    /// Frames to capture after the reference, 1 to 7. Default 4.
    #[serde(default)]
    pub frames: Option<u32>,
    /// Requested captures per second, 0.5 to 10. Default 4. The real spacing is in the labels.
    #[serde(default)]
    pub fps: Option<f32>,
    /// `offscreen` (the default) renders the game's own image and leaves out UI. `window` captures
    /// the game window, UI included.
    #[serde(default)]
    pub source: Option<String>,
    /// Directory to write sheet.png and ref.png into. Default: a new `burst-<unix_ms>` directory
    /// under `$BEVY_FAIRY_CAPTURE_DIR`, or `./debug_captures/` when that variable is unset.
    #[serde(default)]
    pub save_dir: Option<String>,
    /// Also save every frame at full resolution as `frame-<n>.png`. Default false.
    #[serde(default)]
    pub full_frames: Option<bool>,
    /// Also return the contact sheet inline, so the agent sees it in the reply.
    #[serde(default)]
    pub inline: Option<bool>,
}

/// One finished capture as the plugin delivered it.
struct Captured {
    width: u64,
    height: u64,
    png_base64: String,
    png: Vec<u8>,
    source: Option<String>,
    frame_requested: Option<u64>,
    frame_captured: Option<u64>,
}

impl Captured {
    /// What the plugin said about the capture, or that it said nothing.
    fn provenance(&self) -> String {
        match (&self.source, self.frame_requested, self.frame_captured) {
            (Some(source), Some(requested), Some(taken)) => {
                format!("source {source}, requested at frame {requested}, delivered at frame {taken}")
            }
            _ => "source and frame unavailable: this game's plugin does not report them".to_string(),
        }
    }
}

impl BevyDebuggerTools {
    /// Ask the game for one capture and wait for it (15 s at most).
    async fn capture_once(&self, params: serde_json::Map<String, Value>) -> Result<Captured, String> {
        let started = self.call_game(SCREENSHOT_METHOD, Some(Value::Object(params))).await?;
        let Some(id) = started.get("id").and_then(Value::as_u64) else {
            return Err(format!("{SCREENSHOT_METHOD} did not return a capture id: {started}"));
        };
        let deadline = Instant::now() + Duration::from_secs(15);
        let state = loop {
            let state = self.call_game(CAPTURE_METHOD, Some(serde_json::json!({"id": id}))).await?;
            match state.get("status").and_then(Value::as_str) {
                Some("done") => break state,
                Some("failed") => {
                    return Err(state.get("error").and_then(Value::as_str).unwrap_or("the capture failed").to_string());
                }
                Some("pending") => {}
                _ => return Err(format!("{CAPTURE_METHOD} returned an unexpected reply: {state}")),
            }
            if Instant::now() >= deadline {
                return Err(format!("capture {id} still pending after 15 s"));
            }
            tokio::time::sleep(Duration::from_millis(100)).await;
        };
        let dimension = |key: &str| {
            state.get(key).and_then(Value::as_u64).ok_or_else(|| format!("{CAPTURE_METHOD} returned no {key}"))
        };
        let (width, height) = (dimension("width")?, dimension("height")?);
        let Some(png_base64) = state.get("png_base64").and_then(Value::as_str).map(str::to_owned) else {
            return Err(format!("{CAPTURE_METHOD} returned no png_base64"));
        };
        let png = base64::engine::general_purpose::STANDARD
            .decode(&png_base64)
            .map_err(|e| format!("the capture was not valid base64: {e}"))?;
        Ok(Captured {
            width,
            height,
            png_base64,
            png,
            source: state.get("source").and_then(Value::as_str).map(str::to_owned),
            frame_requested: state.get("frame_requested").and_then(Value::as_u64),
            frame_captured: state.get("frame_captured").and_then(Value::as_u64),
        })
    }
}

impl BevyDebuggerTools {
    /// Read `method` between two reads of the game's frame counter, in one HTTP round trip. The
    /// game serves a batch's requests one after another, each possibly on a later frame, so the
    /// value is known only to lie between the two frames. Returns `(frame_before, frame_after, result)`.
    async fn sample_with_frame(&self, method: &str, params: Option<Value>) -> Result<(u64, u64, Value), String> {
        let requests = [
            BrpRequest { method: STATUS_METHOD.to_string(), id: Some(Value::from(1)), params: None },
            BrpRequest { method: method.to_string(), id: Some(Value::from(2)), params },
            BrpRequest { method: STATUS_METHOD.to_string(), id: Some(Value::from(3)), params: None },
        ];
        let mut client = self.brp_client.write().await;
        let answers = match client.send_batch(&requests).await {
            Ok(answers) => answers,
            Err(e) => return Err(why_no_reply(&client, method, &e, "a batch reply")),
        };
        let mut values = Vec::with_capacity(3);
        for (request, answer) in requests.iter().zip(answers) {
            match answer.payload {
                BrpPayload::Result(value) => values.push(value),
                BrpPayload::Error(err) => return Err(crate::server::brp::refusal_text(&request.method, &err)),
            }
        }
        let after = values.pop().unwrap_or(Value::Null);
        let result = values.pop().unwrap_or(Value::Null);
        let before = values.pop().unwrap_or(Value::Null);
        let frame_of = |status: &Value| {
            status
                .get("frame")
                .and_then(Value::as_u64)
                .ok_or_else(|| format!("{STATUS_METHOD} returned no frame counter: {status}"))
        };
        Ok((frame_of(&before)?, frame_of(&after)?, result))
    }
}

impl BevyDebuggerTools {
    /// One BRP call to the game. The `Err` is the text an agent reads: the no-game message when
    /// nothing answers, the game's own error message verbatim, or -- when the game answered with
    /// something this server cannot read as one JSON-RPC reply -- a message that says exactly that.
    async fn call_game(&self, method: &str, params: Option<Value>) -> Result<Value, String> {
        let request = BrpRequest { method: method.to_string(), id: Some(Value::from(1)), params };
        let mut client = self.brp_client.write().await;
        match client.send_request(&request).await {
            Ok(response) => match response.payload {
                BrpPayload::Result(value) => Ok(value),
                BrpPayload::Error(err) => Err(crate::server::brp::refusal_text(method, &err)),
            },
            Err(e) => Err(why_no_reply(&client, method, &e, "one JSON-RPC reply")),
        }
    }

    /// The game's frame counter now, from `bevy_fairy/status`. The `Err` says why there is none:
    /// the game serves BRP without this crate's plugin, or does not answer.
    async fn frame_now(&self) -> Result<u64, String> {
        let status = self.call_game(STATUS_METHOD, None).await?;
        status
            .get("frame")
            .and_then(Value::as_u64)
            .ok_or_else(|| format!("{STATUS_METHOD} returned no frame counter: {status}"))
    }

    /// Say which frames a read lies between: `frame` is the game's frame counter before the
    /// handler ran and `frame_until` after it. The handler's own requests are served on frames of
    /// their own, so the claim is a bracket, as in `watch`. A game that cannot report its frame
    /// gets `frame_unavailable` and the reason, never a made-up number. A refusal is left alone.
    async fn stamp_frames(&self, before: Result<u64, String>, result: &mut Value) {
        if result.get("error").is_some_and(|error| !error.is_null()) {
            return;
        }
        let after = self.frame_now().await;
        let Some(fields) = result.as_object_mut() else {
            return;
        };
        match (before, after) {
            (Ok(from), Ok(until)) => {
                fields.insert("frame".to_string(), Value::from(from));
                fields.insert("frame_until".to_string(), Value::from(until));
            }
            (Err(why), _) | (_, Err(why)) => {
                fields.insert("frame_unavailable".to_string(), Value::from(why));
            }
        }
    }

    async fn game_url(&self) -> String {
        self.brp_client.read().await.url().to_string()
    }

    /// Poll `bevy_fairy/status` until the game reports ready, for at most 120 s.
    async fn wait_until_ready(&self) -> Result<(), String> {
        let deadline = Instant::now() + Duration::from_secs(120);
        loop {
            let status = self.call_game(STATUS_METHOD, None).await?;
            if status.get("ready").and_then(Value::as_bool) == Some(true) {
                return Ok(());
            }
            if Instant::now() >= deadline {
                return Err("game not ready after 120 s".to_string());
            }
            tokio::time::sleep(Duration::from_millis(500)).await;
        }
    }

    /// Every command the game registered, plus `bevy_fairy/time`, with the built-ins last.
    async fn command_list(&self) -> Result<Vec<CommandEntry>, String> {
        let value = self.call_game(COMMANDS_METHOD, None).await?;
        let listing: CommandListing = serde_json::from_value(value)
            .map_err(|e| format!("{COMMANDS_METHOD} returned an unexpected reply: {e}"))?;
        let mut entries = listing.commands;
        if !entries.iter().any(|entry| entry.name == TIME_COMMAND) {
            entries.push(CommandEntry {
                name: TIME_COMMAND.to_string(),
                description: "Pause or slow game time (Time<Virtual>); returns the paused flag and the speed.".to_string(),
                params: "pause: bool (optional); speed: f32 (optional, above 0 and at most 1000)".to_string(),
                example: serde_json::json!({"pause": true}),
            });
        }
        entries.sort_by(|a, b| {
            (a.name.starts_with("bevy_fairy/"), &a.name).cmp(&(b.name.starts_with("bevy_fairy/"), &b.name))
        });
        Ok(entries)
    }
}

/// **How an agent is expected to work with a human through this server.**
///
/// One string, served by this server, because it is a rule and a rule with two copies is two
/// rules.
///
/// It is here rather than in a doc comment because it is the one thing every agent reads before it
/// touches a tool, and the half worth spending words on is the half agents get wrong. `guide` is the
/// only tool in this server that involves a person, and the failure it invites is silence: post a
/// script, go quiet, and somebody is left in front of a card that will never move. That happened
/// during the tool's own development, to the person it was built for, who asked the reasonable
/// question -- "how am I supposed to tell you whether it worked? Am I supposed to come back to the
/// chat?" The answer is yes, and nothing anywhere said so.
pub const COLLABORATION_PROTOCOL: &str =
                "Debugging tools for a RUNNING Bevy game, over the Bevy Remote Protocol.\n\n\
                 DRIVING A GAME THAT REGISTERS ITS OWN COMMANDS: call `status` first (is the game up and \
                 ready?), then `commands` (what can be run, with each one's parameters and an example), then \
                 `run` for each step. `screenshot` saves a PNG on this machine, `scene save` writes the \
                 commands you ran to a replayable file, `brp` calls any Bevy Remote Protocol method, \
                 `watch` samples one over N frames (a value that stops, a speed), and \
                 `quit` closes the game.\n\n\
                 INSPECTING THE GAME: `observe` reads the game's entities from a short phrase `query`. \
                 `detect_anomaly` looks for anomalies in them. `hypothesis` tests a claim about the game \
                 -- it needs a `description` of the claim and a `success_condition` that decides it, or it \
                 cannot run. `experiment` changes the game (spawn, modify, delete) and needs the `actions` \
                 to carry out.\n\n\
                 `detect_anomaly`, `hypothesis` and `experiment` each take an `action` that picks \
                 what to do, and each has its own action names -- read them from the tool's schema. A tool \
                 asked for something it cannot do says so; it does not answer with a default.\n\n\
                 WORKING WITH THE PERSON AT THE KEYBOARD:\n\
                 `guide` is different from the rest -- it talks to the human, not the game. It \
                 draws one instruction at a time on their own window and watches for the state it \
                 asked for. Reach for it when you would otherwise type a numbered list into the \
                 chat and hope they map it onto an interface you cannot see: reproducing a bug, \
                 walking an acceptance test, or checking something only a person can look at.\n\n\
                 Three rules, all learned the hard way:\n\
                 - AFTER POSTING A SCRIPT, SAY SO IN CHAT. Nothing in the app gets their attention. \
                 An agent that posts and goes quiet has hidden the instructions rather than \
                 delivered them.\n\
                 - A step with `checkpoint: null` NEVER ADVANCES ON ITS OWN. It is the question a \
                 machine cannot answer, so ask it in the conversation, by name, and wait for a real \
                 answer before calling `skip`. The card promises them 'tell the agent'; you are the \
                 agent.\n\
                 - WHEN THEY SAY A STEP MADE NO SENSE, THAT IS THE FINDING. Do not work around it. \
                 Most people follow a confusing instruction rather than report it, so the ones you \
                 do get are worth more than a passing run.\n\n\
                 What comes back is k/n per step -- times passed over times attempted -- never a \
                 boolean, because 'it worked' is precisely the judgement people get wrong.\n\n\
                 CAPTURES: the default offscreen capture renders the game's own image and leaves out UI. \
                 `screenshot` with source \"window\" captures the game window with its UI included, without \
                 raising or focusing it. A covered or minimised window can give a stale frame, so use it only \
                 when the UI is what you need to see.";

#[tool_router]
impl BevyDebuggerTools {
    /// Read the game's entities with a phrase query
    #[tool(description = "Read the game's entities with a short phrase query and return them with their component values: every entity, one entity by id, the entities with or without a component, or the registered component types. `diff` compares a read with the previous `diff` read of the same query. The reply says which game frames the read lies between (\"frame\" to \"frame_until\", the counter before and after; the game serves each request on a frame of its own), or \"frame_unavailable\" with the reason when the game does not serve the plugin's status method. Entities only: for a resource use `brp` with world.get_resources, and for a component's fields and their types use `brp` with registry.schema.")]
    pub async fn observe(&self, Parameters(req): Parameters<ObserveRequest>) -> Result<CallToolResult, McpError> {
        debug!("Executing observe query: {}", req.query);

        let before = self.frame_now().await;
        match observe::handle(&req, self.brp_client.clone()).await {
            Ok(mut result) => {
                self.stamp_frames(before, &mut result).await;
                handler_result(result)
            }
            Err(e) => {
                error!("Observe tool error: {}", e);
                tool_err(format!("Observe tool error: {}", e))
            }
        }
    }

    /// Guide a human through the running app
    #[tool(description = "Guide the HUMAN through something in the running app, and get back what actually happened. This is the only tool here that talks to the person rather than the game: you post a short script, the app draws ONE step at a time over their own window, and it advances itself when a named condition arrives. Use it to walk somebody through reproducing a bug, to hand over an acceptance test, or any time you would otherwise type a numbered list into the chat and hope they map it onto an interface you cannot see.\n\nHOW TO COLLABORATE (this part is not optional -- getting it wrong strands them):\n1. Post steps, then TELL THEM IN CHAT that the guide is up and to look at the app window. They will not know otherwise; nothing pops up to get their attention.\n2. Poll with {read:true}. When it answers waiting_on_a_person:true, that step has NO machine check and will never advance on its own. STOP AND ASK THEM IN CHAT, in words, naming the step. The card tells them 'tell the agent' -- that promise is yours to keep. Only after they answer, call {skip:true}. Any other step is being checked by the app every frame: read names that checkpoint, the app moves the step on itself when it passes, and read carries checkpoint_error when the app cannot run it.\n3. When they say a step made no sense or did not work, that is DATA, not a detour. Record it and fix the step or the app. The transcript is k/n per step -- passes over attempts -- because a boolean 'it worked' is the thing people get wrong.\n4. Never post a step whose checkpoint the app has not registered. The app refuses the whole script by name, lists what would have worked, and posts nothing. One verb per call: steps, read, skip or clear; a call naming two is refused.\n\nWrite steps the way you would brief a colleague: say WHY before what (people ignore a suggestion whose reason they cannot see), two to four short imperatives, and always fill in recovery -- what to do when the step does not work is the field that makes this better than a list in a chat window.")]
    pub async fn guide(&self, Parameters(req): Parameters<GuideRequest>) -> Result<CallToolResult, McpError> {
        let mut arguments = serde_json::Map::new();
        if let Some(steps) = req.steps {
            arguments.insert("steps".to_string(), Value::Array(steps));
        }
        if req.read {
            arguments.insert("read".to_string(), Value::Bool(true));
        }
        if req.skip {
            arguments.insert("skip".to_string(), Value::Bool(true));
        }
        if req.clear {
            arguments.insert("clear".to_string(), Value::Bool(true));
        }
        if let Some(visible) = req.visible {
            arguments.insert("visible".to_string(), Value::Bool(visible));
        }
        debug!("Guide request: {} key(s)", arguments.len());

        match guide::handle(Value::Object(arguments), self.brp_client.clone()).await {
            Ok(result) => handler_result(result),
            Err(e) => {
                error!("Guide tool error: {}", e);
                tool_err(format!("Guide tool error: {}", e))
            }
        }
    }

    /// Run controlled experiments on game state
    #[tool(description = "Change the running game and undo it: spawn, modify, delete or batch entities. `action` picks the operation; `execute` carries the `actions` to run, each a spawn, modify, delete or batch, with component types written as full paths (game::Crate), not short names. The reply lists what each action did, taken from the game's own answer: a spawn names its entity, an action the game or this server refuses is a failed result and the rest still run, and a batch lists its own actions. A batch cannot contain a batch. An atomic batch stops at its first failure and reverses what it ran, saying which results were rolled back. A modify's result carries read_back, the written components read from the game once, a frame or so after the write; its matches is false, and the message says so, when the game does not hold what was written (a system sets the value every frame). A system that changes it later is not seen. `undo` and `redo` step through the recorded actions and answer with action_type undo or redo and of_action, the kind of action they stepped over: a delete cannot be reversed, and a modify only when its components existed before. A redo of a spawn makes a different entity, and the redo entries behind it follow it. The history is kept by this server, not the game, and belongs to the game process that ran its first action (the instance in the game's status). After a game restart `undo` and `redo` are refused and the stale history is discarded, because its entity ids now name other entities; `execute` discards it too and says so in history_discarded; `history` says stale. A game that cannot name its process (no plugin, or an older one) leaves the history unverified: the actions still run, and the reply says why in game_instance_unverified. A game that answered without naming itself cannot later be the one that names itself, so once one does, the history it left is stale. A history recorded against a game that did name itself refuses every action, `execute` too, while that game cannot be asked who it is. `clear_history` discards the history.")]
    pub async fn experiment(&self, Parameters(req): Parameters<ExperimentRequest>) -> Result<CallToolResult, McpError> {
        debug!("Running experiment action: {}", req.action);

        let arguments = match request_args(&req) {
            Ok(arguments) => arguments,
            Err(refusal) => return refusal,
        };

        match experiment::handle(arguments, self.brp_client.clone()).await {
            Ok(result) => handler_result(result),
            Err(e) => {
                error!("Experiment tool error: {}", e);
                tool_err(format!("Experiment tool error: {}", e))
            }
        }
    }

    /// Test hypotheses about game behavior
    #[tool(description = "Test a claim about your game against the running game. `action` picks the operation; `test` needs a `description` of the claim and a `success_condition` that decides it. The condition is a string (entity_exists:game::Crate or component_equals:game::Score=100) or an object whose type is entity_exists, component_equals, performance_within (entity_count or component_count), action_succeeds, all, any or not. A condition that cannot be read as written, or that names a component the game never registered, is refused before anything runs. Each iteration runs the `actions`, reads the condition, then reverses what the actions spawned or modified, so every iteration starts from the game as it was found; game_state in the reply counts what was reversed and what stays applied (a delete cannot be put back). A `variation_type` changes the numbers, strings and booleans inside `actions`; with no actions, or none it can change, the call is refused, because identical iterations are not independent trials. The reply's verdict is CONFIRMED, REJECTED, INCONCLUSIVE or NOT_EVALUATED, and statistics counts each iteration as held, violated, or not_evaluated: an action failed first, so the condition was never read, and the examples carry the game's own message. NOT_EVALUATED says nothing about the claim. success_rate and the interval are over the iterations that read the condition; the verdict counts every planned iteration, so held plus violated below iterations_planned keeps it from CONFIRMED. `timeout` bounds the whole run: when it passes the run stops between iterations (one already started finishes) and stopped_early says so, and other tools are answered between iterations. Without variation there is one iteration, so CONFIRMED means the condition held once when read, and the interval says how little that shows.")]
    pub async fn hypothesis(&self, Parameters(req): Parameters<HypothesisRequest>) -> Result<CallToolResult, McpError> {
        debug!("Testing hypothesis action: {}", req.action);

        let arguments = match request_args(&req) {
            Ok(arguments) => arguments,
            Err(refusal) => return refusal,
        };

        match hypothesis::handle(arguments, self.brp_client.clone()).await {
            Ok(result) => handler_result(result),
            Err(e) => {
                error!("Hypothesis tool error: {}", e);
                tool_err(format!("Hypothesis tool error: {}", e))
            }
        }
    }

    /// Detect anomalies in game behavior
    #[tool(description = "Look for anomalies in the game's entities. `action` picks the operation: `detect` reads every entity once and runs three detectors, `configure` sets the thresholds below, `status` lists what each detector reads. The detectors read only particular components, matched on the last segment of the type path: the linear vector of a component named Velocity (scored against that entity's own recent speeds, including the one just read), the current field of Health beside a boolean Alive, and the number of entities across `detect` calls. Speeds and the entity count are judged only after `min_samples` readings, kept between calls, so the first calls judge nothing. Those readings belong to one game process: after a game restart they are dropped, because the new game gives the same entity ids to other entities, and the reply says so in history_discarded; a game that cannot name its process leaves them unverified, with the reason in game_instance_unverified, and they are dropped once a game that names itself answers after one that answered without naming itself. `detect` returns a list of detectors saying, for each, how many entities it read and whether it could judge them, because an empty anomalies list from a detector that read nothing says nothing about the game. Severity, from 0 to 1, is how far past its threshold a speed or entity count is; a Health/Alive contradiction carries a fixed 0.9, a rule's rating and not a measurement. Like `observe`, the reply says which game frames it was read between. A detection is a lead to check, not a verdict.")]
    pub async fn detect_anomaly(&self, Parameters(req): Parameters<AnomalyRequest>) -> Result<CallToolResult, McpError> {
        debug!("Running anomaly action: {}", req.action);

        let arguments = match request_args(&req) {
            Ok(arguments) => arguments,
            Err(refusal) => return refusal,
        };

        // Only `detect` reads the game; `configure` and `status` answer from this server's own state.
        let before = if req.action == "detect" { Some(self.frame_now().await) } else { None };
        match anomaly::handle(arguments, self.brp_client.clone()).await {
            Ok(mut result) => {
                if let Some(before) = before {
                    self.stamp_frames(before, &mut result).await;
                }
                handler_result(result)
            }
            Err(e) => {
                error!("Anomaly detection error: {}", e);
                tool_err(format!("Anomaly detection error: {}", e))
            }
        }
    }

    /// Report whether the game is answering and ready for commands
    #[tool(description = "Report whether the game is answering and ready for commands: its frame, its instance (one value per game process, new after a restart), how many commands it registered, where captures come from, and the progress of a scene file the game is replaying (null when none was asked for; a scene file the game refused is reported with the reason under \"rejected\"). The reply also carries the URL this server talks to. Call this first.")]
    pub async fn status(&self, Parameters(_req): Parameters<NoArgs>) -> Result<CallToolResult, McpError> {
        let url = self.game_url().await;
        match self.call_game(STATUS_METHOD, None).await {
            Ok(mut value) => {
                if let Some(fields) = value.as_object_mut() {
                    fields.insert("url".to_string(), Value::from(url));
                }
                tool_ok(value.to_string())
            }
            Err(text) => tool_err(text),
        }
    }

    /// List the commands the game registered
    #[tool(description = "List the commands the game registered, each with its parameters and a working example. Call this before `run`. The built-in bevy_fairy/time is listed last.")]
    pub async fn commands(&self, Parameters(_req): Parameters<NoArgs>) -> Result<CallToolResult, McpError> {
        match self.command_list().await {
            Ok(entries) => {
                let blocks: Vec<String> = entries.iter().map(command_block).collect();
                tool_ok(blocks.join("\n\n"))
            }
            Err(text) => tool_err(text),
        }
    }

    /// Run one command the game registered
    #[tool(description = "Run one command the game registered, by name, with its parameters. Waits up to 120 s for the game to be ready. An unknown name is refused with the closest known one. A command that changes the game is recorded, so `scene save` can write the run out as a replayable file; a command the game registered as read-only (bevy_fairy/ring and bevy_fairy/ui_text are) answers and is not recorded, because replaying it would change nothing.")]
    pub async fn run(&self, Parameters(req): Parameters<RunRequest>) -> Result<CallToolResult, McpError> {
        if let Err(text) = self.wait_until_ready().await {
            return tool_err(text);
        }
        let entries = match self.command_list().await {
            Ok(entries) => entries,
            Err(text) => return tool_err(text),
        };
        if !entries.iter().any(|entry| entry.name == req.command) {
            return tool_err(unknown_command_text(&req.command, &entries));
        }
        match self.call_game(&req.command, req.params).await {
            Ok(value) => tool_ok(value.to_string()),
            Err(text) => tool_err(text),
        }
    }

    /// Call any Bevy Remote Protocol method the game serves
    #[tool(description = "Call any Bevy Remote Protocol method the game serves, for example world.query or registry.schema, and return its result. Use `run` for the game's own commands. Watching methods (names ending in +watch) are refused: they stream, and this returns one answer per call.")]
    pub async fn brp(&self, Parameters(req): Parameters<BrpCallRequest>) -> Result<CallToolResult, McpError> {
        // A watching method answers with a stream that never ends; reading it as one reply timed
        // out and was reported as "no game answering". Say what it is and what to call instead.
        if let Some(one_shot) = req.method.strip_suffix("+watch") {
            return tool_err(format!(
                "`{}` is a watching method: the game streams a new answer every time the value \
                 changes, and `brp` returns one answer per call, so it cannot read it. Call \
                 `{one_shot}` again for a later value, or `watch` with method `{one_shot}` for a series over frames.",
                req.method
            ));
        }
        match self.call_game(&req.method, req.params).await {
            Ok(value) => tool_ok(value.to_string()),
            Err(text) => tool_err(text),
        }
    }

    /// Capture the game to a PNG file
    #[tool(description = "Capture the game to a PNG file on this machine and return its path, size, the source actually read (offscreen or window) and the game frames the request and the delivery happened on (compare with `status`'s frame to judge freshness). The default offscreen capture leaves out UI; source 'window' captures the game window with its UI included. Pass inline true to also get the image in the reply.")]
    pub async fn screenshot(&self, Parameters(req): Parameters<ScreenshotRequest>) -> Result<CallToolResult, McpError> {
        let mut params = serde_json::Map::new();
        if let Some(source) = req.source {
            params.insert("source".to_string(), Value::from(source));
        }
        if let Some(region) = req.region {
            params.insert(
                "region".to_string(),
                serde_json::json!({"x": region.x, "y": region.y, "width": region.width, "height": region.height}),
            );
        }
        if let Some(zoom) = req.zoom {
            params.insert("zoom".to_string(), Value::from(zoom));
        }
        let captured = match self.capture_once(params).await {
            Ok(captured) => captured,
            Err(text) => return tool_err(text),
        };
        let (width, height, png_base64, png) = (captured.width, captured.height, captured.png_base64.clone(), &captured.png);
        let path = req.save_to.map(PathBuf::from).unwrap_or_else(default_capture_path);
        if let Err(e) = write_file_creating_dirs(&path, png) {
            return tool_err(format!("could not write {}: {e}", path.display()));
        }
        let absolute = std::path::absolute(&path).unwrap_or_else(|_| path.clone());
        let provenance = captured.provenance();
        let mut content = vec![Content::text(format!("saved {} ({width}x{height}); {provenance}", absolute.display()))];
        if req.inline.unwrap_or(false) {
            content.push(Content::image(png_base64.to_string(), "image/png"));
        }
        Ok(CallToolResult::success(content))
    }

    /// Capture a short sequence as a labelled contact sheet
    #[tool(description = "Capture a short sequence of frames and return it as one contact sheet (a PNG, at most 2 rows x 4 columns), so a change over time can be seen: a sprite that stops moving, a flash, a transition. The first cell is a reference frame taken just before the sequence (labelled REF); the rest are `frames` captures at `fps` (default 4, 0.5-10), 1-7 frames. Every cell is labelled with the game frame the capture was delivered on (F<frame>) and the milliseconds since the reference was requested (+<ms>MS), both as reported, so two cells with the same F<frame> are the same game frame (the game is slower than `fps`). Each frame needs a round trip to the game, so the real spacing can exceed 1/fps: read the labels, not the request. Saves sheet.png and ref.png in a new directory (or full-resolution frame-<n>.png files too with full_frames true) and returns their paths. The default offscreen source leaves out UI; source 'window' includes it. Fails, saving nothing, when any capture fails or the frame size changes mid-sequence.")]
    pub async fn burst(&self, Parameters(req): Parameters<BurstRequest>) -> Result<CallToolResult, McpError> {
        let frames = req.frames.unwrap_or(4);
        if !(1..=7).contains(&frames) {
            return tool_err(format!("`frames` must be between 1 and 7 (the reference takes the eighth cell), got {frames}. Example: {{\"frames\": 6, \"fps\": 4}}"));
        }
        let fps = req.fps.unwrap_or(4.0);
        if !(0.5..=10.0).contains(&fps) {
            return tool_err(format!("`fps` must be between 0.5 and 10, got {fps}. Example: {{\"frames\": 6, \"fps\": 4}}"));
        }
        let mut params = serde_json::Map::new();
        if let Some(source) = &req.source {
            params.insert("source".to_string(), Value::from(source.clone()));
        }
        let interval = Duration::from_secs_f64(1.0 / f64::from(fps));
        let started = Instant::now();
        let mut shots: Vec<(Captured, u128)> = Vec::with_capacity(frames as usize + 1);
        for n in 0..=frames {
            if n > 0 {
                tokio::time::sleep_until((started + interval.mul_f64(f64::from(n))).into()).await;
            }
            match self.capture_once(params.clone()).await {
                Ok(captured) => shots.push((captured, started.elapsed().as_millis())),
                Err(text) => return tool_err(format!("burst stopped at capture {n} of {frames}, nothing saved: {text}")),
            }
        }

        let mut cells = Vec::with_capacity(shots.len());
        for (n, (shot, at_ms)) in shots.iter().enumerate() {
            let image = match image::load_from_memory_with_format(&shot.png, image::ImageFormat::Png) {
                Ok(image) => image.to_rgba8(),
                Err(e) => return tool_err(format!("capture {n} was not a readable PNG: {e}")),
            };
            let frame = shot.frame_captured.map_or_else(|| "-".to_string(), |f| f.to_string());
            let label = if n == 0 { format!("REF F{frame}") } else { format!("F{frame} +{at_ms}MS") };
            cells.push(crate::server::contact_sheet::Cell { image, label });
        }
        let sheet = match crate::server::contact_sheet::compose(&cells) {
            Ok(sheet) => sheet,
            Err(text) => return tool_err(format!("{text}; nothing saved")),
        };
        let mut sheet_png = Vec::new();
        if let Err(e) = sheet.write_to(&mut std::io::Cursor::new(&mut sheet_png), image::ImageFormat::Png) {
            return tool_err(format!("could not encode the contact sheet: {e}"));
        }

        let dir = req.save_dir.map(PathBuf::from).unwrap_or_else(default_burst_dir);
        let mut files: Vec<(PathBuf, &[u8])> = vec![(dir.join("sheet.png"), &sheet_png), (dir.join("ref.png"), &shots[0].0.png)];
        let frame_names: Vec<PathBuf> = (1..shots.len()).map(|n| dir.join(format!("frame-{n}.png"))).collect();
        if req.full_frames.unwrap_or(false) {
            for (name, (shot, _)) in frame_names.iter().zip(shots.iter().skip(1)) {
                files.push((name.clone(), &shot.png));
            }
        }
        for (path, bytes) in &files {
            if let Err(e) = write_file_creating_dirs(path, bytes) {
                return tool_err(format!("could not write {}: {e}", path.display()));
            }
        }

        let absolute = |p: &Path| std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf());
        let mut lines = vec![format!(
            "saved contact sheet {} ({}x{}) and reference {}",
            absolute(&files[0].0).display(),
            sheet.width(),
            sheet.height(),
            absolute(&files[1].0).display()
        )];
        if req.full_frames.unwrap_or(false) {
            lines.push(format!("full-resolution frames: {}", frame_names.iter().map(|p| absolute(p).display().to_string()).collect::<Vec<_>>().join(", ")));
        }
        for (n, (shot, at_ms)) in shots.iter().enumerate() {
            let name = if n == 0 { "ref".to_string() } else { format!("frame {n}") };
            lines.push(format!("{name}: +{at_ms} ms, {}", shot.provenance()));
        }
        let distinct: std::collections::BTreeSet<u64> = shots.iter().filter_map(|(s, _)| s.frame_captured).collect();
        lines.push(format!("{} distinct game frames across {} captures", distinct.len(), shots.len()));
        let mut content = vec![Content::text(lines.join("\n"))];
        if req.inline.unwrap_or(false) {
            content.push(Content::image(base64::engine::general_purpose::STANDARD.encode(&sheet_png), "image/png"));
        }
        Ok(CallToolResult::success(content))
    }

    /// Save, show or clear the recorded commands
    #[tool(description = "Work with the commands recorded in this game session. save writes them to a replayable scene file on this machine (path required); show returns them; clear forgets them.")]
    pub async fn scene(&self, Parameters(req): Parameters<SceneRequest>) -> Result<CallToolResult, McpError> {
        match req.action {
            SceneAction::Save => {
                let Some(path) = req.path else {
                    return tool_err("scene save needs a path");
                };
                let scene = match self.call_game(SCENE_METHOD, Some(serde_json::json!({"action": "get"}))).await {
                    Ok(value) => value,
                    Err(text) => return tool_err(text),
                };
                let steps = scene.get("steps").and_then(Value::as_array).map_or(0, Vec::len);
                let text = match serde_json::to_string_pretty(&scene) {
                    Ok(text) => text,
                    Err(e) => return tool_err(format!("could not format the scene: {e}")),
                };
                let path = PathBuf::from(path);
                if let Err(e) = write_file_creating_dirs(&path, text.as_bytes()) {
                    return tool_err(format!("could not write {}: {e}", path.display()));
                }
                let absolute = std::path::absolute(&path).unwrap_or_else(|_| path.clone());
                tool_ok(format!("saved {} ({steps} steps)", absolute.display()))
            }
            SceneAction::Show => {
                let scene = match self.call_game(SCENE_METHOD, Some(serde_json::json!({"action": "get"}))).await {
                    Ok(value) => value,
                    Err(text) => return tool_err(text),
                };
                let steps = scene.get("steps").cloned().unwrap_or_else(|| Value::Array(Vec::new()));
                tool_ok(steps.to_string())
            }
            SceneAction::Clear => match self.call_game(SCENE_METHOD, Some(serde_json::json!({"action": "clear"}))).await {
                Ok(value) => tool_ok(value.to_string()),
                Err(text) => tool_err(text),
            },
        }
    }

    /// Sample a BRP method over time
    #[tool(description = "Sample one BRP method repeatedly and return the series, each sample stamped with the game frames it was read between (\"frame\" to \"frame_until\", usually one apart or equal) and the milliseconds since the first sample. Use it for anything that happens over time: a value that stops changing, a position's speed, a counter that drifts. The reply says whether the value changed, how many distinct values were seen, the frame of the first change, and \"constant_since_frame\" (the frame from which the last value never changed, null when it changed at the last sample). A result with several values changes as a whole when any one of them does, so when the sampled value holds two or more values (a component's fields, the rows of a query) \"fields\" gives each of them those same numbers beside a pointer you can pass back as `pointer`. The values that moved between values they held and then stopped are listed first, then the other values that changed, then the ones that never moved, at most 40, with \"fields_omitted\" counting the rest. \"absent_samples\" counts the samples that lacked a value: that counts as a change, but a value that only appeared or skipped a sample did not stop, and a value gone from the last sample is never reported as constant. An array position is a position, not an identity: a despawn shifts the rows after it. Samples are polled over HTTP at `interval_ms` (20-5000), so changes between samples are not seen; 2-100 samples, 30 s in total at most. Pass `pointer` (JSON pointer into each result) to keep samples small; a pointer that matches nothing is an error. Streaming `+watch` methods are refused.")]
    pub async fn watch(&self, Parameters(req): Parameters<WatchRequest>) -> Result<CallToolResult, McpError> {
        if let Some(one_shot) = req.method.strip_suffix("+watch") {
            return tool_err(format!(
                "`{}` is a streaming method; `watch` polls a one-shot method. Call \
                 `watch` with method `{one_shot}`.",
                req.method
            ));
        }
        let samples = req.samples.unwrap_or(10);
        if !(2..=100).contains(&samples) {
            return tool_err(format!("`samples` must be between 2 and 100, got {samples}. Example: {{\"method\": \"world.query\", \"samples\": 10}}"));
        }
        let interval_ms = req.interval_ms.unwrap_or(100);
        if !(20..=5000).contains(&interval_ms) {
            return tool_err(format!("`interval_ms` must be between 20 and 5000, got {interval_ms}."));
        }
        if u64::from(samples) * interval_ms > WATCH_MAX_TOTAL_MS {
            return tool_err(format!(
                "{samples} samples x {interval_ms} ms is {} s; the most `watch` runs is {} s. Lower `samples` or `interval_ms`.",
                u64::from(samples) * interval_ms / 1000,
                WATCH_MAX_TOTAL_MS / 1000
            ));
        }

        let started = Instant::now();
        let mut series: Vec<(u64, u64, u128, Value)> = Vec::with_capacity(samples as usize);
        let mut stopped: Option<String> = None;
        let mut reply_bytes = 0usize;
        for n in 0..samples {
            if n > 0 {
                tokio::time::sleep(Duration::from_millis(interval_ms)).await;
            }
            let (frame, frame_until, result) = match self.sample_with_frame(&req.method, req.params.clone()).await {
                Ok(pair) => pair,
                Err(text) if series.is_empty() => return tool_err(text),
                Err(text) => {
                    stopped = Some(text);
                    break;
                }
            };
            let value = match &req.pointer {
                None => result,
                Some(pointer) => match result.pointer(pointer) {
                    Some(value) => value.clone(),
                    None if series.is_empty() => {
                        return tool_err(format!(
                            "pointer `{pointer}` matches nothing in the result of `{}`; the result was: {}",
                            req.method,
                            truncate_for_error(&result)
                        ));
                    }
                    None => {
                        stopped = Some(format!("pointer `{pointer}` stopped matching at sample {}", n + 1));
                        break;
                    }
                },
            };
            reply_bytes += value.to_string().len();
            if reply_bytes > WATCH_MAX_REPLY_BYTES {
                return tool_err(format!(
                    "the series passed {WATCH_MAX_REPLY_BYTES} bytes after {n} samples. Pass `pointer` to pick the value to watch, or lower `samples`."
                ));
            }
            series.push((frame, frame_until, started.elapsed().as_millis(), value));
        }

        let frames: Vec<u64> = series.iter().map(|(frame, _, _, _)| *frame).collect();
        let values: Vec<&Value> = series.iter().map(|(_, _, _, value)| value).collect();
        let whole: Vec<Option<&Value>> = values.iter().copied().map(Some).collect();
        let whole = watch_fields::movement(&frames, &whole);
        // A result with several fields changes as a whole when any one does, so the summary
        // above cannot say which of them stopped. `fields` can.
        let fields = watch_fields::fields(&frames, &values, req.pointer.as_deref().unwrap_or(""));
        let mut body = serde_json::json!({
            "method": req.method,
            "source": "polled over HTTP. Each sample is bracketed by two bevy_fairy/status reads: the value was read on a frame from `frame` to `frame_until`",
            "interval_ms": interval_ms,
            "samples_taken": series.len(),
            "changed": whole.changed,
            "distinct_values": whole.distinct_values,
            "first_change_frame": whole.first_change_frame,
            "constant_since_frame": whole.constant_since_frame,
            "stopped_early": stopped,
            "samples": series
                .iter()
                .map(|(frame, frame_until, t_ms, value)| serde_json::json!({"frame": frame, "frame_until": frame_until, "t_ms": t_ms, "value": value}))
                .collect::<Vec<_>>(),
        });
        if let Some(fields) = &fields {
            body["fields"] = fields.to_json();
            if fields.omitted() > 0 {
                body["fields_omitted"] = serde_json::json!(fields.omitted());
            }
        }
        tool_ok(body.to_string())
    }

    /// Ask the game to quit
    #[tool(description = "Ask the game to quit cleanly.")]
    pub async fn quit(&self, Parameters(_req): Parameters<NoArgs>) -> Result<CallToolResult, McpError> {
        match self.call_game(QUIT_METHOD, None).await {
            Ok(value) => tool_ok(value.to_string()),
            Err(text) => tool_err(text),
        }
    }
}

// `#[tool_handler]` is not used: the `list_tools` it generates returns the router's `HashMap`
// values, so the order changed on every launch. The two methods it would generate are written
// out below, and `list_tools` sorts by name.
impl ServerHandler for BevyDebuggerTools {
    async fn call_tool(
        &self,
        request: CallToolRequestParam,
        context: rmcp::service::RequestContext<rmcp::RoleServer>,
    ) -> Result<CallToolResult, McpError> {
        let call = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
        self.tool_router.call(call).await
    }

    async fn list_tools(
        &self,
        _request: Option<PaginatedRequestParam>,
        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
    ) -> Result<ListToolsResult, McpError> {
        let mut tools = self.tool_router.list_all();
        tools.sort_by(|a, b| a.name.cmp(&b.name));
        Ok(ListToolsResult::with_all_items(tools))
    }

    fn get_info(&self) -> ServerInfo {
        ServerInfo {
            protocol_version: ProtocolVersion::V_2024_11_05,
            capabilities: ServerCapabilities::builder().enable_tools().build(),
            server_info: Implementation {
                name: "bevy-fairy".to_string(),
                version: env!("CARGO_PKG_VERSION").to_string(),
            },
            // **The collaboration protocol lives here, because this is the one string every agent
            // reads before it touches a tool.** It was one sentence describing the server, which is
            // the least useful thing it could say: a tool list already describes the server.
            //
            // The half worth spending words on is the half agents get wrong. `guide` is the only
            // tool here that involves a person, and the failure it invites is silence -- post a
            // script, go quiet, and somebody is left in front of a card that will never move. That
            // happened during the tool's own development, to the person it was built for, who asked
            // the reasonable question: "how am I supposed to tell you whether it worked? Am I
            // supposed to come back to the chat?" The answer is yes, and nothing said so.
            instructions: Some(COLLABORATION_PROTOCOL.to_string()),
        }
    }
}