Skip to main content

scrybe_tools/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Shawn Hartsock and contributors
3
4//! Scrybe tools — one [`ToolSpec`] registry shared by the CLI and the MCP server.
5//!
6//! Foundation crate for the MCP rebuild (issue #122; design
7//! `docs/design/mcp-rebuild.md`). A single [`Registry`] is consumed by *both*
8//! front ends, so CLI↔MCP parity holds by construction: same handler, different
9//! envelope. This first slice is deliberately additive — the core types, the
10//! [`Registry`], the headless [`Transport`], and the pure `render` tool.
11//! Dispatch-through-`scrybe-rpc`, the remaining tools, and the protocol fixes
12//! land in later phases (design §8).
13
14use serde_json::Value;
15
16pub mod autolaunch;
17pub mod figures;
18pub mod lint;
19pub mod schema;
20pub mod tools;
21
22pub use figures::{export_figures, plan_figures, FigurePlan, FigureResult};
23
24/// Tool group — drives progressive disclosure and feature gating (design §4).
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Facet {
27    Core,
28    Editor,
29    Mermaid,
30    Vcs,
31    UiParity,
32    Swarm,
33}
34
35/// Versioned schema for a tool's stable `data` payload. Agents read `data`; they
36/// never parse `description` prose or the human `text` (design §4A).
37#[derive(Clone, Copy)]
38pub struct DataSchema {
39    /// Payload version, bumped when the shape changes so agents can pin.
40    pub version: u32,
41    /// JSON Schema for the `data` object.
42    pub schema: fn() -> Value,
43}
44
45/// A business-level failure: the tool ran and said "no" (e.g. "heading not
46/// found"). This is DATA carried inside the outcome, not an engine fault.
47/// Each surface decides its own presentation: the MCP adapter reports a
48/// failed invocation (`isError: true` with the `{code, message}` in
49/// `structuredContent` — A4); the CLI prints the error and keeps its exit
50/// semantics.
51#[derive(Debug, Clone, serde::Serialize)]
52pub struct ToolError {
53    /// Stable machine code, e.g. `"heading_not_found"`.
54    pub code: String,
55    /// Human/agent-readable explanation.
56    pub message: String,
57}
58
59impl ToolError {
60    /// Convenience constructor.
61    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
62        Self {
63            code: code.into(),
64            message: message.into(),
65        }
66    }
67}
68
69/// The result of a *successful* tool invocation. A `Some(tool_error)` still
70/// means the call succeeded — the tool told the agent "no". Engine faults are
71/// the separate [`EngineFault`] type returned by the dispatcher.
72#[derive(Debug, Clone)]
73pub struct ToolOutcome {
74    /// Typed, versioned payload, serialized under `data` on every surface.
75    pub data: Value,
76    /// Business failure, if any. `None` == the tool did its job.
77    pub tool_error: Option<ToolError>,
78}
79
80impl ToolOutcome {
81    /// A successful outcome with no business error.
82    pub fn ok(data: Value) -> Self {
83        Self {
84            data,
85            tool_error: None,
86        }
87    }
88
89    /// A successful call that carries a business failure.
90    pub fn fail(data: Value, error: ToolError) -> Self {
91        Self {
92            data,
93            tool_error: Some(error),
94        }
95    }
96
97    /// True when there is no business error.
98    pub fn is_ok(&self) -> bool {
99        self.tool_error.is_none()
100    }
101}
102
103/// One tool, shared verbatim by the CLI and the MCP server (design §2.2).
104pub struct ToolSpec {
105    /// Wire name, e.g. `"render"`. Also the CLI subcommand stem.
106    pub name: &'static str,
107    /// Human/agent-facing description. This is ALSO the embedded agent prompt —
108    /// it carries behavioral guidance, not just a label — and is rendered into
109    /// MCP `tools/list` and `scrybe <cmd> --help` verbatim.
110    pub description: &'static str,
111    /// JSON Schema for arguments (MCP `inputSchema`; also drives CLI arg parse).
112    pub input_schema: fn() -> Value,
113    /// Versioned, typed schema for the tool's stable `data` payload.
114    pub data_schema: DataSchema,
115    /// Does this tool change editor/disk/app state? Gates read-only agents and
116    /// dry-run mode.
117    pub mutates: bool,
118    /// Tool group for progressive disclosure + feature gating.
119    pub facet: Facet,
120    /// The one implementation, shared by both front ends. `Ok(outcome)` even
121    /// for business failures (a `tool_error` inside the outcome); `Err` is
122    /// reserved for [`EngineFault`]s — e.g. the transport failing mid-request.
123    pub handler: fn(&Ctx, &Value) -> Result<ToolOutcome, EngineFault>,
124}
125
126/// Engine fault: the dispatcher could not even run the tool (unknown tool, bad
127/// arguments, transport down). Surfaces as a non-zero CLI exit; on MCP,
128/// `UnknownTool` is a JSON-RPC `-32602` protocol error and the rest are
129/// `isError: true` results (A4 mapping in `scrybe-mcp-server/src/server.rs`).
130/// Distinct from a business [`ToolError`] carried inside the outcome
131/// (design §2.2, §5).
132#[derive(Debug, thiserror::Error, PartialEq, Eq)]
133pub enum EngineFault {
134    /// No tool with this name is registered.
135    #[error("unknown tool: {0}")]
136    UnknownTool(String),
137    /// Arguments failed validation before the handler ran.
138    #[error("invalid arguments: {0}")]
139    BadArgs(String),
140    /// The transport to the live app failed mid-request — socket I/O,
141    /// timeouts, oversized/garbled frames, or a malformed JSON-RPC envelope.
142    /// The app did NOT answer, so there is no business outcome to report:
143    /// this is an engine fault, never a `tool_error`. (The two conditions the
144    /// app *does* answer for stay business outcomes: no app at all →
145    /// `no_live_app`, and an in-band remote error → `app_error`.)
146    #[error("transport error: {0}")]
147    Transport(String),
148}
149
150/// Failure talking to the live app over the socket, in three semantic classes
151/// that mirror [`scrybe_rpc::ClientError`]'s taxonomy.
152#[derive(Debug, thiserror::Error)]
153pub enum TransportError {
154    /// No Scrybe app is running to service the request
155    /// ([`scrybe_rpc::ClientError::is_not_running`]). A business condition —
156    /// tools report it as the `no_live_app` `tool_error`.
157    #[error("no Scrybe app is running")]
158    NoApp,
159    /// The app ANSWERED with an in-band JSON-RPC error object. The transport
160    /// worked; this is a business outcome (tools report it as `app_error`).
161    #[error("app error {}: {}", .0.code, .0.message)]
162    Remote(scrybe_rpc::RpcError),
163    /// The transport itself failed: socket I/O, timeouts, frame/UTF-8/JSON
164    /// problems, or an envelope violation. The app did not answer — dispatch
165    /// maps this to [`EngineFault::Transport`], never to a business outcome.
166    #[error("{0}")]
167    Transport(String),
168}
169
170/// Round-trips scrybe-rpc requests to the live app (design §2.3). The only place
171/// a tool touches the outside world — the future `LiveApp` transport and the
172/// modulex extraction seam both live behind this trait.
173pub trait Transport {
174    /// Round-trip a scrybe-rpc `method` with `params` over `~/.scrybe/sock`.
175    fn call(&self, method: &str, params: Value) -> Result<Value, TransportError>;
176    /// Is a live app currently reachable?
177    fn is_live(&self) -> bool;
178}
179
180/// No socket: only the pure, GUI-free subset of tools runs. GUI/stateful tools
181/// get a clean `NoApp`.
182pub struct Headless;
183
184impl Transport for Headless {
185    fn call(&self, _method: &str, _params: Value) -> Result<Value, TransportError> {
186        Err(TransportError::NoApp)
187    }
188
189    fn is_live(&self) -> bool {
190        false
191    }
192}
193
194/// Dials the **live app** over `~/.scrybe/sock` via `scrybe-rpc`'s client — the
195/// same wire the CLI uses, so a tool routed through this transport drives the
196/// running editor exactly like `scrybe <cmd>` does. When no app is running,
197/// `call` returns `NoApp` and pure tools should fall back to `Headless`.
198pub struct LiveApp;
199
200/// Map a `scrybe-rpc` client result onto the transport error taxonomy: no app
201/// running → [`TransportError::NoApp`], an in-band remote error →
202/// [`TransportError::Remote`], anything else (I/O, timeout, frame) →
203/// [`TransportError::Transport`]. Used for the post-auto-launch retry.
204fn map_send(res: Result<Value, scrybe_rpc::ClientError>) -> Result<Value, TransportError> {
205    match res {
206        Ok(result) => Ok(result),
207        Err(e) if e.is_not_running() => Err(TransportError::NoApp),
208        Err(scrybe_rpc::ClientError::Remote(err)) => Err(TransportError::Remote(err)),
209        Err(e) => Err(TransportError::Transport(e.to_string())),
210    }
211}
212
213impl Transport for LiveApp {
214    fn call(&self, method: &str, params: Value) -> Result<Value, TransportError> {
215        match scrybe_rpc::client::send(method, params.clone()) {
216            Ok(result) => Ok(result),
217            // No app running. If auto-launch is opted in (`SCRYBE_MCP_AUTOLAUNCH`),
218            // start the installed app, wait for its socket, and retry once;
219            // otherwise report NoApp (→ the tool's clean `no_live_app`).
220            Err(e) if e.is_not_running() => {
221                if crate::autolaunch::autolaunch_and_wait() {
222                    map_send(scrybe_rpc::client::send(method, params))
223                } else {
224                    Err(TransportError::NoApp)
225                }
226            }
227            // In-band remote error: the app answered. Business, not engine.
228            Err(scrybe_rpc::ClientError::Remote(err)) => Err(TransportError::Remote(err)),
229            // Everything else (I/O, timeouts, frame/UTF-8/JSON, envelope,
230            // mismatched id): the transport failed — an engine-fault class.
231            Err(e) => Err(TransportError::Transport(e.to_string())),
232        }
233    }
234
235    fn is_live(&self) -> bool {
236        scrybe_rpc::client::is_live()
237    }
238}
239
240/// Handler execution context — carries the transport a stateful tool would use.
241pub struct Ctx {
242    /// How stateful tools reach the live app.
243    pub transport: Box<dyn Transport>,
244}
245
246impl Ctx {
247    /// A context with no live app: pure tools only.
248    pub fn headless() -> Self {
249        Self {
250            transport: Box::new(Headless),
251        }
252    }
253
254    /// A context that dials the live app over `~/.scrybe/sock` (`LiveApp`).
255    pub fn live() -> Self {
256        Self {
257            transport: Box::new(LiveApp),
258        }
259    }
260
261    /// A context with an explicit transport.
262    pub fn with_transport(transport: Box<dyn Transport>) -> Self {
263        Self { transport }
264    }
265}
266
267/// The shared tool registry consumed by both front ends.
268pub struct Registry {
269    tools: Vec<ToolSpec>,
270}
271
272impl Registry {
273    /// An empty registry.
274    pub fn new() -> Self {
275        Self { tools: Vec::new() }
276    }
277
278    /// Register a tool. Panics on a duplicate name — a programming error, caught
279    /// in tests, never reachable from user input.
280    pub fn register(&mut self, spec: ToolSpec) {
281        assert!(
282            self.get(spec.name).is_none(),
283            "duplicate tool registered: {}",
284            spec.name
285        );
286        self.tools.push(spec);
287    }
288
289    /// Look up a tool by wire name.
290    pub fn get(&self, name: &str) -> Option<&ToolSpec> {
291        self.tools.iter().find(|t| t.name == name)
292    }
293
294    /// All registered tool names, in registration order.
295    pub fn names(&self) -> Vec<&'static str> {
296        self.tools.iter().map(|t| t.name).collect()
297    }
298
299    /// Number of registered tools.
300    pub fn len(&self) -> usize {
301        self.tools.len()
302    }
303
304    /// True when no tools are registered.
305    pub fn is_empty(&self) -> bool {
306        self.tools.is_empty()
307    }
308
309    /// Dispatch a call. An unknown tool, a missing required argument, or a
310    /// transport failure mid-request is an [`EngineFault`] (`Err`); a business
311    /// failure is `Ok(ToolOutcome { tool_error: Some(..) })`.
312    pub fn call(&self, name: &str, ctx: &Ctx, args: &Value) -> Result<ToolOutcome, EngineFault> {
313        let spec = self
314            .get(name)
315            .ok_or_else(|| EngineFault::UnknownTool(name.to_string()))?;
316        require_args(&(spec.input_schema)(), args)?;
317        (spec.handler)(ctx, args)
318    }
319}
320
321impl Default for Registry {
322    /// The default registry with every built-in tool registered.
323    fn default() -> Self {
324        let mut reg = Self::new();
325        tools::register_defaults(&mut reg);
326        reg
327    }
328}
329
330/// Minimal JSON-Schema `required` check — the dispatcher's argument gate until a
331/// full validator lands. A missing required key is an [`EngineFault::BadArgs`].
332fn require_args(schema: &Value, args: &Value) -> Result<(), EngineFault> {
333    if let Some(required) = schema.get("required").and_then(Value::as_array) {
334        for key in required.iter().filter_map(Value::as_str) {
335            if args.get(key).is_none() {
336                return Err(EngineFault::BadArgs(format!(
337                    "missing required argument: {key}"
338                )));
339            }
340        }
341    }
342    Ok(())
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use serde_json::json;
349
350    #[test]
351    fn default_registry_registers_render() {
352        let reg = Registry::default();
353        assert!(reg.get("render").is_some());
354        assert!(reg.names().contains(&"render"));
355        assert!(!reg.is_empty());
356    }
357
358    #[test]
359    fn unknown_tool_is_engine_fault() {
360        let reg = Registry::default();
361        let err = reg
362            .call("does_not_exist", &Ctx::headless(), &json!({}))
363            .unwrap_err();
364        assert_eq!(err, EngineFault::UnknownTool("does_not_exist".into()));
365    }
366
367    #[test]
368    fn missing_required_arg_is_bad_args() {
369        let reg = Registry::default();
370        // `render` requires `source`; omitting it is an engine fault, not a
371        // business error — the dispatcher rejects it before the handler runs.
372        let err = reg
373            .call("render", &Ctx::headless(), &json!({}))
374            .unwrap_err();
375        assert!(
376            matches!(err, EngineFault::BadArgs(ref m) if m.contains("source")),
377            "expected BadArgs mentioning source, got {err:?}"
378        );
379    }
380
381    #[test]
382    fn headless_transport_has_no_live_app() {
383        let ctx = Ctx::headless();
384        assert!(!ctx.transport.is_live());
385        assert!(ctx.transport.call("open", json!({})).is_err());
386    }
387
388    #[test]
389    fn live_app_transport_is_wired_and_fails_cleanly_without_an_app() {
390        // Robust regardless of whether a dev app happens to be running: the call
391        // must never panic, and when no app is reachable it must error (NoApp),
392        // never silently succeed.
393        let ctx = Ctx::live();
394        if !ctx.transport.is_live() {
395            assert!(
396                ctx.transport.call("state", json!({})).is_err(),
397                "no live app → call must error"
398            );
399        }
400    }
401
402    #[test]
403    #[should_panic(expected = "duplicate tool")]
404    fn duplicate_registration_panics() {
405        let mut reg = Registry::new();
406        reg.register(tools::render::spec());
407        reg.register(tools::render::spec());
408    }
409
410    #[test]
411    fn every_data_schema_is_an_honest_envelope_never_a_placeholder() {
412        // A4: no tool may serve a bare `{"type":"object"}` placeholder. Every
413        // data schema wraps its payload in the shared envelope: `v` pinned to
414        // the spec's version, `kind` pinned to the tool name, `tool_error`
415        // described as the optional business-failure object.
416        let reg = Registry::default();
417        for name in reg.names() {
418            let spec = reg.get(name).unwrap();
419            let schema = (spec.data_schema.schema)();
420            assert_eq!(
421                schema["properties"]["kind"]["const"], name,
422                "tool {name}: `kind` must be pinned to the tool name"
423            );
424            assert_eq!(
425                schema["properties"]["v"]["const"], spec.data_schema.version,
426                "tool {name}: `v` must be pinned to the data-schema version"
427            );
428            assert!(
429                schema["properties"]["tool_error"].is_object(),
430                "tool {name}: envelope must describe the optional tool_error"
431            );
432            let required = schema["required"].as_array().unwrap();
433            assert!(
434                required.contains(&json!("v")) && required.contains(&json!("kind")),
435                "tool {name}: v and kind are required envelope keys"
436            );
437            assert!(
438                !required.contains(&json!("tool_error")),
439                "tool {name}: tool_error is never required (absent on success)"
440            );
441        }
442    }
443}