jumperless-mcp 0.1.0

MCP server for the Jumperless V5 — persistent USB-serial bridge exposing the firmware API to LLMs
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
//! Probe ToolDefs: `jumperless.probe.*`
//!
//! Three tools for interacting with the Jumperless V5's touch-sensing probe tip
//! and the two physical buttons (CONNECT / REMOVE) mounted on the probe handle.
//!
//! ## Device API notes (from 09.5-micropythonAPIreference.md):
//! - `probe_read_blocking()` — alias for `probe_read(blocking=True)`.
//!   Blocks until the user touches the probe tip to a pad; returns a `ProbePad`
//!   object. When printed, `ProbePad` renders as an integer row (e.g., `25`)
//!   for numbered rows or a named constant string (e.g., `D13_PAD`, `TOP_RAIL`,
//!   `NO_PAD`) for special pads. Always try `int()` first; fall back to string.
//! - `probe_read_nonblocking()` — alias for `probe_read(blocking=False)`.
//!   Returns immediately; prints as `-1` when no pad is touched.
//! - `probe_button()` — default is blocking=True per device API. Called
//!   WITHOUT arguments here (picks up the default, which maps to nonblocking in
//!   our usage because we explicitly pass `blocking=False`).
//!   Returns one of three printed strings: `CONNECT`, `REMOVE`, or `NONE`.
//!
//! ## Response ambiguity note:
//! `probe_read_blocking()` / `probe_read_nonblocking()` can return int-like
//! strings (`"25"`) or name strings (`"D13_PAD"`, `"TOP_RAIL"`, `"NO_PAD"`).
//! Both handlers use the same parse_probe_pad helper: try i64 first, then
//! keep as string. MCP callers must handle `row` being either a JSON number or
//! a JSON string.

use crate::base::{McpError, ToolDescriptor};
use serde_json::{json, Value};
use std::io::{Read, Write};

use crate::library::exec_with_cleanup;

/// Empty input schema for zero-arg tools.
fn no_args() -> Value {
    json!({"type": "object", "properties": {}, "additionalProperties": false})
}

// ── Shared helpers ────────────────────────────────────────────────────────────

/// Parse a ProbePad device response: try integer first, fall back to string.
///
/// Empty input is an Err (not a silent `json!("")` fallback). Without the
/// empty-guard, `parse_probe_pad("")` would return `(json!(""), "")` and the
/// nonblocking handler would compute `touched=true` (since `json!("") !=
/// json!(-1)`), reporting a phantom touch. Flagged IMPORTANT by both reviewers
/// 2026-05-12.
fn parse_probe_pad(raw: &str) -> Result<(Value, String), McpError> {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return Err(McpError::Protocol(
            "probe: device returned empty response (expected row number or pad name)".into(),
        ));
    }
    match trimmed.parse::<i64>() {
        Ok(n) => Ok((json!(n), raw.to_string())),
        Err(_) => Ok((json!(trimmed), raw.to_string())),
    }
}

// ── ToolDescriptors ───────────────────────────────────────────────────────────

/// Wait for the user to touch the probe tip to a row, then return that row.
///
/// Wraps `probe_read_blocking()`. Returns `{"row": int_or_string, "raw": str}`.
pub fn probe_read_blocking_descriptor() -> ToolDescriptor {
    ToolDescriptor::with_timeout(
        "probe_read_blocking",
        "BLOCKS up to 60s waiting for user probe touch — do NOT call unless you intend \
         to wait for physical user interaction. Waits for the user to touch the Jumperless V5 \
         probe tip to a breadboard row or named pad, then returns that location. \
         Returns {\"row\": <int or string>, \"raw\": <device string>}. \
         `row` is an integer for numbered rows (1-60) or a string for named pads \
         (e.g., \"TOP_RAIL\", \"D13_PAD\", \"NO_PAD\"). \
         `raw` preserves the exact device output for disambiguation.",
        no_args(),
        60_000,
    )
}

/// Check probe touch state without blocking.
///
/// Wraps `probe_read_nonblocking()`. Returns `{"row": int_or_string, "touched": bool}`.
pub fn probe_read_nonblocking_descriptor() -> ToolDescriptor {
    ToolDescriptor::with_timeout(
        "probe_read_nonblocking",
        "Check whether the Jumperless V5 probe tip is currently touching a pad, \
         without blocking. Returns immediately. \
         Returns {\"row\": <int or string>, \"touched\": bool}. \
         When no pad is touched, `row` is -1 and `touched` is false. \
         For numbered rows (1-60), `row` is an integer and `touched` is true. \
         For named pads (e.g., \"TOP_RAIL\", \"D13_PAD\"), `row` is a string and `touched` is true.",
        no_args(),
        1_000,
    )
}

/// Read the current state of the probe buttons without blocking.
///
/// Wraps `probe_button(blocking=False)`. Returns `{"button": "CONNECT" | "REMOVE" | "NONE"}`.
pub fn probe_button_descriptor() -> ToolDescriptor {
    ToolDescriptor::with_timeout(
        "probe_button",
        "Read the current state of the physical buttons on the Jumperless V5 probe handle. \
         Returns immediately (non-blocking). \
         Returns {\"button\": \"CONNECT\"} when the front button is pressed, \
         {\"button\": \"REMOVE\"} when the rear button is pressed, or \
         {\"button\": \"NONE\"} when no button is pressed. \
         Any response outside these three values is an error.",
        no_args(),
        1_000,
    )
}

/// Return all three probe ToolDescriptors.
pub fn descriptors() -> Vec<ToolDescriptor> {
    vec![
        probe_read_blocking_descriptor(),
        probe_read_nonblocking_descriptor(),
        probe_button_descriptor(),
    ]
}

// ── Handlers ─────────────────────────────────────────────────────────────────

/// Execute `probe_read_blocking()` and return `{"row": int_or_string, "raw": str}`.
///
/// The timeout on the descriptor (60 000 ms) gives the user a full minute to
/// place the probe. Response parsing: try i64 first (numbered row 1-60), fall
/// back to string (named pad constant or `NO_PAD`).
pub fn handle_probe_read_blocking<P: Read + Write + ?Sized>(
    port: &mut P,
) -> Result<Value, McpError> {
    let code = "print(probe_read_blocking())";
    let resp = exec_with_cleanup(port, code, "probe_read_blocking")?;
    let raw = resp.stdout.trim().to_string();
    let (row, _) = parse_probe_pad(&raw)?;
    Ok(json!({ "row": row, "raw": raw }))
}

/// Execute `probe_read_nonblocking()` and return `{"row": int_or_string, "touched": bool}`.
///
/// When no pad is touched the device prints `-1`. All other values are parsed
/// via `parse_probe_pad`: integer rows (1-60) → `{"row": <int>, "touched": true}`,
/// named constants (e.g., `"TOP_RAIL"`) → `{"row": "<string>", "touched": true}`.
pub fn handle_probe_read_nonblocking<P: Read + Write + ?Sized>(
    port: &mut P,
) -> Result<Value, McpError> {
    let code = "print(probe_read_nonblocking())";
    let resp = exec_with_cleanup(port, code, "probe_read_nonblocking")?;
    let raw = resp.stdout.trim().to_string();
    let (row, _) = parse_probe_pad(&raw)?;
    // -1 as integer → no touch.
    let touched = row != json!(-1_i64);
    Ok(json!({ "row": row, "touched": touched }))
}

// ── Tests for empty-response guard (IMPORTANT finding 2026-05-12) ───────────
//
// parse_probe_pad previously returned (json!(""), "") for empty input, which
// made probe_read_nonblocking compute touched=true (since json!("") != json!(-1)).
// Phantom-touch bug. Post-fix: empty input is Err.

/// Execute `probe_button(blocking=False)` and return `{"button": str}`.
///
/// Strict validation: only `"CONNECT"`, `"REMOVE"`, and `"NONE"` are accepted.
/// Any other response (empty string, unexpected firmware output) is an Err.
/// Mirrors the strict-validation pattern in `handle_context_get` (context.rs:71-75)
/// and `handle_slot_has_changes` (slot.rs:149-156).
pub fn handle_probe_button<P: Read + Write + ?Sized>(port: &mut P) -> Result<Value, McpError> {
    let code = "print(probe_button(blocking=False))";
    let resp = exec_with_cleanup(port, code, "probe_button")?;
    let trimmed = resp.stdout.trim().to_string();
    match trimmed.as_str() {
        "CONNECT" | "REMOVE" | "NONE" => Ok(json!({ "button": trimmed })),
        other => Err(McpError::Protocol(format!(
            "probe_button: unexpected device response: '{other}' \
             (expected CONNECT, REMOVE, or NONE)"
        ))),
    }
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::VecDeque;
    use std::io::{self, Read, Write};

    // ── MockPort ──────────────────────────────────────────────────────────────

    struct MockPort {
        read_data: VecDeque<u8>,
        pub write_data: Vec<u8>,
    }

    impl MockPort {
        fn with_responses(responses: &[&[u8]]) -> Self {
            let mut buf = Vec::new();
            for r in responses {
                buf.extend_from_slice(r);
            }
            MockPort {
                read_data: VecDeque::from(buf),
                write_data: Vec::new(),
            }
        }

        fn ok_with_stdout(line: &str) -> Vec<u8> {
            let mut v = b"OK".to_vec();
            v.extend_from_slice(line.as_bytes());
            v.push(b'\n');
            v.extend_from_slice(b"\x04\x04>");
            v
        }

        fn error_frame(msg: &str) -> Vec<u8> {
            let mut v = b"OK\x04".to_vec();
            v.extend_from_slice(msg.as_bytes());
            v.push(b'\n');
            v.push(b'\x04');
            v.push(b'>');
            v
        }
    }

    impl Read for MockPort {
        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
            let n = buf.len().min(self.read_data.len());
            if n == 0 {
                return Err(io::Error::new(
                    io::ErrorKind::UnexpectedEof,
                    "MockPort: no more scripted bytes",
                ));
            }
            for (dst, src) in buf[..n].iter_mut().zip(self.read_data.drain(..n)) {
                *dst = src;
            }
            Ok(n)
        }
    }

    impl Write for MockPort {
        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
            self.write_data.extend_from_slice(buf);
            Ok(buf.len())
        }
        fn flush(&mut self) -> io::Result<()> {
            Ok(())
        }
    }

    // ── Descriptor tests ──────────────────────────────────────────────────────

    #[test]
    fn all_descriptors_have_correct_names() {
        let descs = descriptors();
        let names: Vec<&str> = descs.iter().map(|d| d.name.as_str()).collect();
        assert!(names.contains(&"probe_read_blocking"));
        assert!(names.contains(&"probe_read_nonblocking"));
        assert!(names.contains(&"probe_button"));
        assert_eq!(descs.len(), 3);
    }

    #[test]
    fn all_descriptors_have_object_schema_with_additional_properties_false() {
        for d in descriptors() {
            assert!(
                matches!(d.input_schema, Value::Object(_)),
                "descriptor '{}' must have object input_schema",
                d.name
            );
            assert_eq!(
                d.input_schema.get("additionalProperties"),
                Some(&Value::Bool(false)),
                "descriptor '{}' must have additionalProperties=false",
                d.name
            );
        }
    }

    #[test]
    fn probe_read_blocking_description_warns_about_blocking() {
        let d = probe_read_blocking_descriptor();
        // Description must WARN callers that this blocks for up to 60s.
        let desc_upper = d.description.to_uppercase();
        assert!(
            desc_upper.contains("BLOCKS"),
            "probe_read_blocking description must contain 'BLOCKS'; got: {}",
            d.description
        );
        assert!(
            d.description.contains("60"),
            "probe_read_blocking description must mention 60s timeout; got: {}",
            d.description
        );
    }

    // ── Handler: probe_read_blocking ─────────────────────────────────────────

    #[test]
    fn probe_read_blocking_integer_row() {
        // Device returns a numbered row (e.g., row 42).
        let frame = MockPort::ok_with_stdout("42");
        let mut port = MockPort::with_responses(&[&frame]);
        let result = handle_probe_read_blocking(&mut port).unwrap();
        assert_eq!(result["row"], json!(42_i64));
        assert_eq!(result["raw"], json!("42"));
    }

    #[test]
    fn probe_read_blocking_string_node_name() {
        // Device returns a named pad constant (e.g., TOP_RAIL).
        let frame = MockPort::ok_with_stdout("TOP_RAIL");
        let mut port = MockPort::with_responses(&[&frame]);
        let result = handle_probe_read_blocking(&mut port).unwrap();
        assert_eq!(result["row"], json!("TOP_RAIL"));
        assert_eq!(result["raw"], json!("TOP_RAIL"));
    }

    // ── Handler: probe_read_nonblocking ──────────────────────────────────────

    #[test]
    fn probe_read_nonblocking_integer_row_touched_true() {
        // Row 5 → touched = true.
        let frame = MockPort::ok_with_stdout("5");
        let mut port = MockPort::with_responses(&[&frame]);
        let result = handle_probe_read_nonblocking(&mut port).unwrap();
        assert_eq!(result["row"], json!(5_i64));
        assert_eq!(result["touched"], json!(true));
    }

    #[test]
    fn probe_read_nonblocking_minus_one_touched_false() {
        // -1 means no pad is touched.
        let frame = MockPort::ok_with_stdout("-1");
        let mut port = MockPort::with_responses(&[&frame]);
        let result = handle_probe_read_nonblocking(&mut port).unwrap();
        assert_eq!(result["row"], json!(-1_i64));
        assert_eq!(result["touched"], json!(false));
    }

    #[test]
    fn probe_read_nonblocking_string_node_name_touched_true() {
        // Named pad constants are treated as touched (not -1).
        let frame = MockPort::ok_with_stdout("D7");
        let mut port = MockPort::with_responses(&[&frame]);
        let result = handle_probe_read_nonblocking(&mut port).unwrap();
        assert_eq!(result["row"], json!("D7"));
        assert_eq!(result["touched"], json!(true));
    }

    // ── Handler: probe_button ─────────────────────────────────────────────────

    #[test]
    fn probe_button_connect() {
        let frame = MockPort::ok_with_stdout("CONNECT");
        let mut port = MockPort::with_responses(&[&frame]);
        let result = handle_probe_button(&mut port).unwrap();
        assert_eq!(result["button"], json!("CONNECT"));
    }

    #[test]
    fn probe_button_remove() {
        let frame = MockPort::ok_with_stdout("REMOVE");
        let mut port = MockPort::with_responses(&[&frame]);
        let result = handle_probe_button(&mut port).unwrap();
        assert_eq!(result["button"], json!("REMOVE"));
    }

    #[test]
    fn probe_button_none() {
        let frame = MockPort::ok_with_stdout("NONE");
        let mut port = MockPort::with_responses(&[&frame]);
        let result = handle_probe_button(&mut port).unwrap();
        assert_eq!(result["button"], json!("NONE"));
    }

    #[test]
    fn probe_read_blocking_empty_response_returns_err() {
        // Post-fix (IMPORTANT 2026-05-12): empty device output must Err rather
        // than silently produce `{row: "", raw: ""}`. The previous tuple-return
        // shape of parse_probe_pad was unable to express this — fixed by
        // returning Result.
        let frame = MockPort::ok_with_stdout("");
        let mut port = MockPort::with_responses(&[&frame]);
        let result = handle_probe_read_blocking(&mut port);
        assert!(result.is_err(), "empty device response must Err");
        match result.unwrap_err() {
            McpError::Protocol(msg) => assert!(
                msg.contains("empty"),
                "error must mention empty response; got: {msg}"
            ),
            other => panic!("expected Protocol err, got: {other:?}"),
        }
    }

    #[test]
    fn probe_read_nonblocking_empty_response_returns_err() {
        // The phantom-touch bug: previously empty input → json!("") row +
        // touched=true. Post-fix: Err.
        let frame = MockPort::ok_with_stdout("");
        let mut port = MockPort::with_responses(&[&frame]);
        let result = handle_probe_read_nonblocking(&mut port);
        assert!(
            result.is_err(),
            "empty device response must Err (was producing phantom touched=true)"
        );
    }

    #[test]
    fn probe_button_unexpected_value_returns_err() {
        // Anything outside CONNECT / REMOVE / NONE must be a hard error.
        let frame = MockPort::ok_with_stdout("CONNECT_BUTTON");
        let mut port = MockPort::with_responses(&[&frame]);
        let result = handle_probe_button(&mut port);
        assert!(result.is_err(), "unexpected device output must return Err");
        match result.unwrap_err() {
            McpError::Protocol(msg) => {
                assert!(
                    msg.contains("unexpected"),
                    "error must describe unexpected value; got: {msg}"
                );
                assert!(
                    msg.contains("CONNECT_BUTTON"),
                    "error must include the actual value; got: {msg}"
                );
            }
            other => panic!("expected McpError::Protocol, got: {other:?}"),
        }
    }

    #[test]
    fn probe_button_empty_response_returns_err() {
        // Empty stdout must not silently coerce to any valid state.
        let frame = MockPort::ok_with_stdout("");
        let mut port = MockPort::with_responses(&[&frame]);
        let result = handle_probe_button(&mut port);
        assert!(
            result.is_err(),
            "empty device output must return Err for probe_button"
        );
    }

    #[test]
    fn probe_button_device_error_sends_ctrl_c() {
        let err = MockPort::error_frame("NameError: probe_button");
        let mut port = MockPort::with_responses(&[&err]);
        let result = handle_probe_button(&mut port);
        assert!(result.is_err());
        // exec_with_cleanup must have sent Ctrl-C (0x03) on firmware error.
        assert!(port.write_data.contains(&0x03));
    }
}