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}