bevy_fairy 0.2.2

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
/*
 * 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;
use serde_json::Value;
use std::{sync::Arc, future::Future};
use tokio::sync::RwLock;
use tracing::{error, info, debug};
use schemars::JsonSchema;

use crate::server::brp_client::BrpClient;
use crate::server::tools::{observe, experiment, guide, hypothesis, anomaly, replay};
use crate::server::experiment_system::Action;
use crate::server::brp::{BrpPayload, BrpRequest};
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)]
pub struct ObserveRequest {
    pub query: String,
    #[serde(default)]
    pub diff: bool,
    #[serde(default)]
    pub detailed: bool,
    #[serde(default)]
    pub reflection: 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)]
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, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ExperimentRequest {
    /// The operation to run. One of `execute`, `undo`, `redo`, `history`, `clear_history`,
    /// `archetypes`.
    pub action: String,
    /// The actions to carry out. Required by `execute`; ignored by the other operations.
    #[serde(default)]
    pub actions: Vec<Action>,
}

/// `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, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct HypothesisRequest {
    /// The operation to run. One of `test`, `quick_test`, `stress_test`, `validate`.
    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. `test` and `quick_test` require it.
    #[serde(default)]
    pub success_condition: Option<Value>,
    /// The actions to try while testing the claim.
    #[serde(default)]
    pub actions: Vec<Action>,
    /// How to vary the test between iterations. One of `none`, `random`, `parameter`, `scenario`.
    #[serde(default)]
    pub variation_type: Option<String>,
    /// For `random`: the low end of the numeric range to vary over.
    #[serde(default)]
    pub numeric_min: Option<f64>,
    /// For `random`: the high end of the numeric range to vary over.
    #[serde(default)]
    pub numeric_max: Option<f64>,
    /// For `random`: the strings to vary over.
    #[serde(default)]
    pub string_pool: Option<Vec<String>>,
    /// For `parameter`: which parameter to vary.
    #[serde(default)]
    pub parameter_name: Option<String>,
    /// For `parameter`: the values to vary over.
    #[serde(default)]
    pub parameter_values: Option<Vec<Value>>,
    /// How many times to run the test.
    #[serde(default)]
    pub iterations: Option<u64>,
    /// How long the test may run, in seconds.
    #[serde(default)]
    pub timeout: Option<u64>,
    /// The seed, so a run can be repeated exactly.
    #[serde(default)]
    pub seed: Option<u64>,
    /// The confidence a result needs before it counts as confirmed.
    #[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, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AnomalyRequest {
    /// The operation to run. One of `detect`, `configure`, `start_monitoring`, `stop_monitoring`,
    /// `status`.
    pub action: String,
    /// How many samples the window covers.
    #[serde(default)]
    pub window_size: Option<u64>,
    /// The z-score above which a sample counts as anomalous.
    #[serde(default)]
    pub z_score_threshold: Option<f64>,
    /// The IQR multiplier above which a sample counts as anomalous.
    #[serde(default)]
    pub iqr_multiplier: Option<f64>,
    /// The fewest samples needed before a verdict is possible.
    #[serde(default)]
    pub min_samples: Option<u64>,
    /// The frame time, in milliseconds, above which performance counts as anomalous.
    #[serde(default)]
    pub performance_threshold: Option<f64>,
    /// The entity growth per sample above which the count counts as anomalous.
    #[serde(default)]
    pub entity_growth_threshold: Option<f64>,
    /// The lowest severity to report. The handler reads this as a number.
    #[serde(default)]
    pub min_severity: Option<f64>,
    /// How many anomalies to report at most.
    #[serde(default)]
    pub limit: Option<u64>,
}

#[derive(Debug, Deserialize, JsonSchema)]
pub struct ReplayRequest {
    pub action: String,
    pub checkpoint_id: Option<String>,
    #[serde(default = "default_speed")]
    pub speed: f32,
}

/// 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,
    #[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 by this factor. Default 1.
    #[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 {
    pub action: SceneAction,
    /// Required for `save`: the file to write, on this machine.
    #[serde(default)]
    pub path: Option<String>,
}

// Default value functions
fn default_speed() -> f32 { 1.0 }

/// 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")
}

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 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"))
}

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)),
            },
            // The client marks itself connected once the game has answered with a success
            // status; a reply 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.
            Err(e) if client.is_connected() => Err(format!(
                "bevy-fairy: the game at {} answered `{method}`, but not with one JSON-RPC reply \
                 this server can read: {e}",
                client.url()
            )),
            Err(e) => {
                debug!("BRP call {method} did not reach the game: {e}");
                Err(no_game_text(client.url()))
            }
        }
    }

    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, must be > 0)".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, and \
                 `quit` closes the game.\n\n\
                 INSPECTING THE GAME: `observe` reads the game's state. `detect_anomaly` looks for \
                 anomalies in it. `hypothesis` tests a claim about it -- it needs a `description` of \
                 the claim and a `success_condition` that decides it, or it cannot run. \
                 `experiment` changes it (spawn, modify, delete) and needs the `actions` to carry \
                 out. `replay` records and replays what it holds.\n\n\
                 Every one of these takes 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 {
    /// Observe and query Bevy game state
    #[tool(description = "Observe and query Bevy game state in real-time with optional reflection-based component inspection. Use this to inspect entities, components, resources, and game state. Enable 'reflection' parameter for deep component analysis including field inspection, type information, and custom inspectors for complex types like Option<T>, Vec<T>, HashMap<K,V>. Perfect for debugging entity spawning, component updates, and understanding your ECS architecture.")]
    pub async fn observe(&self, Parameters(req): Parameters<ObserveRequest>) -> Result<CallToolResult, McpError> {
        debug!("Executing observe query: {}", req.query);
        
        let arguments = serde_json::json!({
            "query": req.query,
            "diff": req.diff,
            "detailed": req.detailed,
            "reflection": req.reflection,
        });
        
        match observe::handle(arguments, self.brp_client.clone()).await {
            Ok(result) => handler_result(result),
            Err(e) => {
                error!("Observe tool error: {}", e);
                Err(McpError::internal_error(format!("Observe tool error: {}", e), None))
            }
        }
    }

    /// 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}.\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 it by name and lists what would have worked, but the person is already standing there by then.\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);
                Err(McpError::internal_error(format!("Guide tool error: {}", e), None))
            }
        }
    }

    /// Run controlled experiments on game state
    #[tool(description = "Run controlled experiments on your Bevy game: spawn, modify or delete entities and read back what changed. `action` picks the operation; `execute` carries the `actions` to run.")]
    pub async fn experiment(&self, Parameters(req): Parameters<ExperimentRequest>) -> Result<CallToolResult, McpError> {
        debug!("Running experiment action: {}", req.action);

        let arguments = serde_json::json!({
            "action": req.action,
            "actions": req.actions,
        });

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

    /// 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.")]
    pub async fn hypothesis(&self, Parameters(req): Parameters<HypothesisRequest>) -> Result<CallToolResult, McpError> {
        debug!("Testing hypothesis action: {}", req.action);

        let arguments = serde_json::json!({
            "action": req.action,
            "description": req.description,
            "success_condition": req.success_condition,
            "actions": req.actions,
            "variation_type": req.variation_type,
            "numeric_min": req.numeric_min,
            "numeric_max": req.numeric_max,
            "string_pool": req.string_pool,
            "parameter_name": req.parameter_name,
            "parameter_values": req.parameter_values,
            "iterations": req.iterations,
            "timeout": req.timeout,
            "seed": req.seed,
            "threshold": req.threshold,
        });

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

    /// 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 the detectors over them with the thresholds below. 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 = serde_json::json!({
            "action": req.action,
            "window_size": req.window_size,
            "z_score_threshold": req.z_score_threshold,
            "iqr_multiplier": req.iqr_multiplier,
            "min_samples": req.min_samples,
            "performance_threshold": req.performance_threshold,
            "entity_growth_threshold": req.entity_growth_threshold,
            "min_severity": req.min_severity,
            "limit": req.limit,
        });

        match anomaly::handle(arguments, self.brp_client.clone()).await {
            Ok(result) => handler_result(result),
            Err(e) => {
                error!("Anomaly detection error: {}", e);
                Err(McpError::internal_error(format!("Anomaly detection error: {}", e), None))
            }
        }
    }

    /// Record and replay game state
    #[tool(description = "Record and replay game state for time-travel debugging. Capture game state at specific points and replay to understand how bugs occur.")]
    pub async fn replay(&self, Parameters(req): Parameters<ReplayRequest>) -> Result<CallToolResult, McpError> {
        info!("Replay action: {}", req.action);
        
        let arguments = serde_json::json!({
            "action": req.action,
            "checkpoint_id": req.checkpoint_id,
            "speed": req.speed,
        });
        
        match replay::handle(arguments, self.brp_client.clone()).await {
            Ok(result) => handler_result(result),
            Err(e) => {
                error!("Replay tool error: {}", e);
                Err(McpError::internal_error(format!("Replay tool error: {}", e), None))
            }
        }
    }

    /// Report whether the game is answering and ready for commands
    #[tool(description = "Report whether the game is answering and ready for commands: its frame, how many commands it registered, where captures come from, and replay progress. 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. Every command that runs is recorded, so `scene save` can write the run out as a replayable file.")]
    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 to see a later value.",
                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 and size. 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 started = match self.call_game(SCREENSHOT_METHOD, Some(Value::Object(params))).await {
            Ok(value) => value,
            Err(text) => return tool_err(text),
        };
        let Some(id) = started.get("id").and_then(Value::as_u64) else {
            return tool_err(format!("{SCREENSHOT_METHOD} did not return a capture id: {started}"));
        };
        let deadline = Instant::now() + Duration::from_secs(15);
        let captured = loop {
            let state = match self.call_game(CAPTURE_METHOD, Some(serde_json::json!({"id": id}))).await {
                Ok(value) => value,
                Err(text) => return tool_err(text),
            };
            let status = state.get("status").and_then(Value::as_str).map(str::to_owned);
            match status.as_deref() {
                Some("done") => break state,
                Some("failed") => {
                    let why = state.get("error").and_then(Value::as_str).unwrap_or("the capture failed");
                    return tool_err(why);
                }
                Some("pending") => {}
                _ => return tool_err(format!("{CAPTURE_METHOD} returned an unexpected reply: {state}")),
            }
            if Instant::now() >= deadline {
                return tool_err(format!("capture {id} still pending after 15 s"));
            }
            tokio::time::sleep(Duration::from_millis(100)).await;
        };
        let width = captured.get("width").and_then(Value::as_u64).unwrap_or_default();
        let height = captured.get("height").and_then(Value::as_u64).unwrap_or_default();
        let Some(png_base64) = captured.get("png_base64").and_then(Value::as_str).map(str::to_owned) else {
            return tool_err(format!("{CAPTURE_METHOD} returned no png_base64"));
        };
        let png = match base64::engine::general_purpose::STANDARD.decode(&png_base64) {
            Ok(bytes) => bytes,
            Err(e) => return tool_err(format!("the capture was not valid base64: {e}")),
        };
        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 mut content = vec![Content::text(format!("saved {} ({width}x{height})", absolute.display()))];
        if req.inline.unwrap_or(false) {
            content.push(Content::image(png_base64.to_string(), "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),
            },
        }
    }

    /// 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()),
        }
    }
}