scrybe-tools 0.6.3

Scrybe tools — one ToolSpec registry shared by the CLI and the MCP server
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
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Shawn Hartsock and contributors

//! Scrybe tools — one [`ToolSpec`] registry shared by the CLI and the MCP server.
//!
//! Foundation crate for the MCP rebuild (issue #122; design
//! `docs/design/mcp-rebuild.md`). A single [`Registry`] is consumed by *both*
//! front ends, so CLI↔MCP parity holds by construction: same handler, different
//! envelope. This first slice is deliberately additive — the core types, the
//! [`Registry`], the headless [`Transport`], and the pure `render` tool.
//! Dispatch-through-`scrybe-rpc`, the remaining tools, and the protocol fixes
//! land in later phases (design §8).

use serde_json::Value;

pub mod autolaunch;
pub mod figures;
pub mod lint;
pub mod schema;
pub mod tools;

pub use figures::{export_figures, plan_figures, FigurePlan, FigureResult};

/// Tool group — drives progressive disclosure and feature gating (design §4).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Facet {
    Core,
    Editor,
    Mermaid,
    Vcs,
    UiParity,
    Swarm,
}

/// Versioned schema for a tool's stable `data` payload. Agents read `data`; they
/// never parse `description` prose or the human `text` (design §4A).
#[derive(Clone, Copy)]
pub struct DataSchema {
    /// Payload version, bumped when the shape changes so agents can pin.
    pub version: u32,
    /// JSON Schema for the `data` object.
    pub schema: fn() -> Value,
}

/// A business-level failure: the tool ran and said "no" (e.g. "heading not
/// found"). This is DATA carried inside the outcome, not an engine fault.
/// Each surface decides its own presentation: the MCP adapter reports a
/// failed invocation (`isError: true` with the `{code, message}` in
/// `structuredContent` — A4); the CLI prints the error and keeps its exit
/// semantics.
#[derive(Debug, Clone, serde::Serialize)]
pub struct ToolError {
    /// Stable machine code, e.g. `"heading_not_found"`.
    pub code: String,
    /// Human/agent-readable explanation.
    pub message: String,
}

impl ToolError {
    /// Convenience constructor.
    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
        Self {
            code: code.into(),
            message: message.into(),
        }
    }
}

/// The result of a *successful* tool invocation. A `Some(tool_error)` still
/// means the call succeeded — the tool told the agent "no". Engine faults are
/// the separate [`EngineFault`] type returned by the dispatcher.
#[derive(Debug, Clone)]
pub struct ToolOutcome {
    /// Typed, versioned payload, serialized under `data` on every surface.
    pub data: Value,
    /// Business failure, if any. `None` == the tool did its job.
    pub tool_error: Option<ToolError>,
}

impl ToolOutcome {
    /// A successful outcome with no business error.
    pub fn ok(data: Value) -> Self {
        Self {
            data,
            tool_error: None,
        }
    }

    /// A successful call that carries a business failure.
    pub fn fail(data: Value, error: ToolError) -> Self {
        Self {
            data,
            tool_error: Some(error),
        }
    }

    /// True when there is no business error.
    pub fn is_ok(&self) -> bool {
        self.tool_error.is_none()
    }
}

/// One tool, shared verbatim by the CLI and the MCP server (design §2.2).
pub struct ToolSpec {
    /// Wire name, e.g. `"render"`. Also the CLI subcommand stem.
    pub name: &'static str,
    /// Human/agent-facing description. This is ALSO the embedded agent prompt —
    /// it carries behavioral guidance, not just a label — and is rendered into
    /// MCP `tools/list` and `scrybe <cmd> --help` verbatim.
    pub description: &'static str,
    /// JSON Schema for arguments (MCP `inputSchema`; also drives CLI arg parse).
    pub input_schema: fn() -> Value,
    /// Versioned, typed schema for the tool's stable `data` payload.
    pub data_schema: DataSchema,
    /// Does this tool change editor/disk/app state? Gates read-only agents and
    /// dry-run mode.
    pub mutates: bool,
    /// Tool group for progressive disclosure + feature gating.
    pub facet: Facet,
    /// The one implementation, shared by both front ends. `Ok(outcome)` even
    /// for business failures (a `tool_error` inside the outcome); `Err` is
    /// reserved for [`EngineFault`]s — e.g. the transport failing mid-request.
    pub handler: fn(&Ctx, &Value) -> Result<ToolOutcome, EngineFault>,
}

/// Engine fault: the dispatcher could not even run the tool (unknown tool, bad
/// arguments, transport down). Surfaces as a non-zero CLI exit; on MCP,
/// `UnknownTool` is a JSON-RPC `-32602` protocol error and the rest are
/// `isError: true` results (A4 mapping in `scrybe-mcp-server/src/server.rs`).
/// Distinct from a business [`ToolError`] carried inside the outcome
/// (design §2.2, §5).
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum EngineFault {
    /// No tool with this name is registered.
    #[error("unknown tool: {0}")]
    UnknownTool(String),
    /// Arguments failed validation before the handler ran.
    #[error("invalid arguments: {0}")]
    BadArgs(String),
    /// The transport to the live app failed mid-request — socket I/O,
    /// timeouts, oversized/garbled frames, or a malformed JSON-RPC envelope.
    /// The app did NOT answer, so there is no business outcome to report:
    /// this is an engine fault, never a `tool_error`. (The two conditions the
    /// app *does* answer for stay business outcomes: no app at all →
    /// `no_live_app`, and an in-band remote error → `app_error`.)
    #[error("transport error: {0}")]
    Transport(String),
}

/// Failure talking to the live app over the socket, in three semantic classes
/// that mirror [`scrybe_rpc::ClientError`]'s taxonomy.
#[derive(Debug, thiserror::Error)]
pub enum TransportError {
    /// No Scrybe app is running to service the request
    /// ([`scrybe_rpc::ClientError::is_not_running`]). A business condition —
    /// tools report it as the `no_live_app` `tool_error`.
    #[error("no Scrybe app is running")]
    NoApp,
    /// The app ANSWERED with an in-band JSON-RPC error object. The transport
    /// worked; this is a business outcome (tools report it as `app_error`).
    #[error("app error {}: {}", .0.code, .0.message)]
    Remote(scrybe_rpc::RpcError),
    /// The transport itself failed: socket I/O, timeouts, frame/UTF-8/JSON
    /// problems, or an envelope violation. The app did not answer — dispatch
    /// maps this to [`EngineFault::Transport`], never to a business outcome.
    #[error("{0}")]
    Transport(String),
}

/// Round-trips scrybe-rpc requests to the live app (design §2.3). The only place
/// a tool touches the outside world — the future `LiveApp` transport and the
/// modulex extraction seam both live behind this trait.
pub trait Transport {
    /// Round-trip a scrybe-rpc `method` with `params` over `~/.scrybe/sock`.
    fn call(&self, method: &str, params: Value) -> Result<Value, TransportError>;
    /// Is a live app currently reachable?
    fn is_live(&self) -> bool;
}

/// No socket: only the pure, GUI-free subset of tools runs. GUI/stateful tools
/// get a clean `NoApp`.
pub struct Headless;

impl Transport for Headless {
    fn call(&self, _method: &str, _params: Value) -> Result<Value, TransportError> {
        Err(TransportError::NoApp)
    }

    fn is_live(&self) -> bool {
        false
    }
}

/// Dials the **live app** over `~/.scrybe/sock` via `scrybe-rpc`'s client — the
/// same wire the CLI uses, so a tool routed through this transport drives the
/// running editor exactly like `scrybe <cmd>` does. When no app is running,
/// `call` returns `NoApp` and pure tools should fall back to `Headless`.
pub struct LiveApp;

/// Map a `scrybe-rpc` client result onto the transport error taxonomy: no app
/// running → [`TransportError::NoApp`], an in-band remote error →
/// [`TransportError::Remote`], anything else (I/O, timeout, frame) →
/// [`TransportError::Transport`]. Used for the post-auto-launch retry.
fn map_send(res: Result<Value, scrybe_rpc::ClientError>) -> Result<Value, TransportError> {
    match res {
        Ok(result) => Ok(result),
        Err(e) if e.is_not_running() => Err(TransportError::NoApp),
        Err(scrybe_rpc::ClientError::Remote(err)) => Err(TransportError::Remote(err)),
        Err(e) => Err(TransportError::Transport(e.to_string())),
    }
}

impl Transport for LiveApp {
    fn call(&self, method: &str, params: Value) -> Result<Value, TransportError> {
        match scrybe_rpc::client::send(method, params.clone()) {
            Ok(result) => Ok(result),
            // No app running. If auto-launch is opted in (`SCRYBE_MCP_AUTOLAUNCH`),
            // start the installed app, wait for its socket, and retry once;
            // otherwise report NoApp (→ the tool's clean `no_live_app`).
            Err(e) if e.is_not_running() => {
                if crate::autolaunch::autolaunch_and_wait() {
                    map_send(scrybe_rpc::client::send(method, params))
                } else {
                    Err(TransportError::NoApp)
                }
            }
            // In-band remote error: the app answered. Business, not engine.
            Err(scrybe_rpc::ClientError::Remote(err)) => Err(TransportError::Remote(err)),
            // Everything else (I/O, timeouts, frame/UTF-8/JSON, envelope,
            // mismatched id): the transport failed — an engine-fault class.
            Err(e) => Err(TransportError::Transport(e.to_string())),
        }
    }

    fn is_live(&self) -> bool {
        scrybe_rpc::client::is_live()
    }
}

/// Handler execution context — carries the transport a stateful tool would use.
pub struct Ctx {
    /// How stateful tools reach the live app.
    pub transport: Box<dyn Transport>,
}

impl Ctx {
    /// A context with no live app: pure tools only.
    pub fn headless() -> Self {
        Self {
            transport: Box::new(Headless),
        }
    }

    /// A context that dials the live app over `~/.scrybe/sock` (`LiveApp`).
    pub fn live() -> Self {
        Self {
            transport: Box::new(LiveApp),
        }
    }

    /// A context with an explicit transport.
    pub fn with_transport(transport: Box<dyn Transport>) -> Self {
        Self { transport }
    }
}

/// The shared tool registry consumed by both front ends.
pub struct Registry {
    tools: Vec<ToolSpec>,
}

impl Registry {
    /// An empty registry.
    pub fn new() -> Self {
        Self { tools: Vec::new() }
    }

    /// Register a tool. Panics on a duplicate name — a programming error, caught
    /// in tests, never reachable from user input.
    pub fn register(&mut self, spec: ToolSpec) {
        assert!(
            self.get(spec.name).is_none(),
            "duplicate tool registered: {}",
            spec.name
        );
        self.tools.push(spec);
    }

    /// Look up a tool by wire name.
    pub fn get(&self, name: &str) -> Option<&ToolSpec> {
        self.tools.iter().find(|t| t.name == name)
    }

    /// All registered tool names, in registration order.
    pub fn names(&self) -> Vec<&'static str> {
        self.tools.iter().map(|t| t.name).collect()
    }

    /// Number of registered tools.
    pub fn len(&self) -> usize {
        self.tools.len()
    }

    /// True when no tools are registered.
    pub fn is_empty(&self) -> bool {
        self.tools.is_empty()
    }

    /// Dispatch a call. An unknown tool, a missing required argument, or a
    /// transport failure mid-request is an [`EngineFault`] (`Err`); a business
    /// failure is `Ok(ToolOutcome { tool_error: Some(..) })`.
    pub fn call(&self, name: &str, ctx: &Ctx, args: &Value) -> Result<ToolOutcome, EngineFault> {
        let spec = self
            .get(name)
            .ok_or_else(|| EngineFault::UnknownTool(name.to_string()))?;
        require_args(&(spec.input_schema)(), args)?;
        (spec.handler)(ctx, args)
    }
}

impl Default for Registry {
    /// The default registry with every built-in tool registered.
    fn default() -> Self {
        let mut reg = Self::new();
        tools::register_defaults(&mut reg);
        reg
    }
}

/// Minimal JSON-Schema `required` check — the dispatcher's argument gate until a
/// full validator lands. A missing required key is an [`EngineFault::BadArgs`].
fn require_args(schema: &Value, args: &Value) -> Result<(), EngineFault> {
    if let Some(required) = schema.get("required").and_then(Value::as_array) {
        for key in required.iter().filter_map(Value::as_str) {
            if args.get(key).is_none() {
                return Err(EngineFault::BadArgs(format!(
                    "missing required argument: {key}"
                )));
            }
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn default_registry_registers_render() {
        let reg = Registry::default();
        assert!(reg.get("render").is_some());
        assert!(reg.names().contains(&"render"));
        assert!(!reg.is_empty());
    }

    #[test]
    fn unknown_tool_is_engine_fault() {
        let reg = Registry::default();
        let err = reg
            .call("does_not_exist", &Ctx::headless(), &json!({}))
            .unwrap_err();
        assert_eq!(err, EngineFault::UnknownTool("does_not_exist".into()));
    }

    #[test]
    fn missing_required_arg_is_bad_args() {
        let reg = Registry::default();
        // `render` requires `source`; omitting it is an engine fault, not a
        // business error — the dispatcher rejects it before the handler runs.
        let err = reg
            .call("render", &Ctx::headless(), &json!({}))
            .unwrap_err();
        assert!(
            matches!(err, EngineFault::BadArgs(ref m) if m.contains("source")),
            "expected BadArgs mentioning source, got {err:?}"
        );
    }

    #[test]
    fn headless_transport_has_no_live_app() {
        let ctx = Ctx::headless();
        assert!(!ctx.transport.is_live());
        assert!(ctx.transport.call("open", json!({})).is_err());
    }

    #[test]
    fn live_app_transport_is_wired_and_fails_cleanly_without_an_app() {
        // Robust regardless of whether a dev app happens to be running: the call
        // must never panic, and when no app is reachable it must error (NoApp),
        // never silently succeed.
        let ctx = Ctx::live();
        if !ctx.transport.is_live() {
            assert!(
                ctx.transport.call("state", json!({})).is_err(),
                "no live app → call must error"
            );
        }
    }

    #[test]
    #[should_panic(expected = "duplicate tool")]
    fn duplicate_registration_panics() {
        let mut reg = Registry::new();
        reg.register(tools::render::spec());
        reg.register(tools::render::spec());
    }

    #[test]
    fn every_data_schema_is_an_honest_envelope_never_a_placeholder() {
        // A4: no tool may serve a bare `{"type":"object"}` placeholder. Every
        // data schema wraps its payload in the shared envelope: `v` pinned to
        // the spec's version, `kind` pinned to the tool name, `tool_error`
        // described as the optional business-failure object.
        let reg = Registry::default();
        for name in reg.names() {
            let spec = reg.get(name).unwrap();
            let schema = (spec.data_schema.schema)();
            assert_eq!(
                schema["properties"]["kind"]["const"], name,
                "tool {name}: `kind` must be pinned to the tool name"
            );
            assert_eq!(
                schema["properties"]["v"]["const"], spec.data_schema.version,
                "tool {name}: `v` must be pinned to the data-schema version"
            );
            assert!(
                schema["properties"]["tool_error"].is_object(),
                "tool {name}: envelope must describe the optional tool_error"
            );
            let required = schema["required"].as_array().unwrap();
            assert!(
                required.contains(&json!("v")) && required.contains(&json!("kind")),
                "tool {name}: v and kind are required envelope keys"
            );
            assert!(
                !required.contains(&json!("tool_error")),
                "tool {name}: tool_error is never required (absent on success)"
            );
        }
    }
}