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