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}