Skip to main content

brep_mcp_core/tools/
mod.rs

1//! The server's view of a tool.
2//!
3//! A [`ToolSpec`] is what every registry entry becomes on its way to
4//! `tools/list`: a name, a group, a doc string, a JSON Schema for its input, MCP
5//! annotations, and an async handler. Nothing in this module enumerates engine
6//! facts; the sets at the bottom *read* them. When the app's command registry
7//! lands (spec §3.4, Appendix A) an adapter turns each `CommandSpec` into a
8//! `ToolSpec` in exactly this form, so `tools/list` stays one uniform walk.
9pub mod app;
10pub mod compose;
11
12use crate::schema;
13use serde_json::{json, Map, Value};
14use std::{future::Future, pin::Pin, sync::Arc};
15
16pub type ToolFuture = Pin<Box<dyn Future<Output = Result<ToolOutput, String>> + Send>>;
17
18/// What a tool call produced: a JSON object (always) and zero or more images.
19#[derive(Default)]
20pub struct ToolOutput {
21    pub json: Value,
22    pub images: Vec<ToolImage>,
23}
24
25pub struct ToolImage {
26    pub png: Vec<u8>,
27    pub mime: &'static str,
28}
29
30impl ToolOutput {
31    pub fn json(v: Value) -> Self {
32        Self { json: v, images: Vec::new() }
33    }
34}
35
36/// MCP tool annotations plus the server's own `waits` flag (the tool applies
37/// the idle contract before returning).
38#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
39pub struct Annotations {
40    pub read_only: bool,
41    pub destructive: bool,
42    pub idempotent: bool,
43    pub waits: bool,
44}
45
46impl Annotations {
47    pub const READ: Annotations = Annotations { read_only: true, destructive: false, idempotent: true, waits: false };
48    pub const MUTATE: Annotations = Annotations { read_only: false, destructive: false, idempotent: false, waits: true };
49    pub const DESTRUCTIVE: Annotations = Annotations { read_only: false, destructive: true, idempotent: false, waits: true };
50}
51
52#[derive(Clone)]
53pub struct ToolSpec {
54    pub name: String,
55    pub group: &'static str,
56    pub doc: String,
57    pub input_schema: Value,
58    pub annotations: Annotations,
59    pub handler: Arc<dyn Fn(Value) -> ToolFuture + Send + Sync>,
60}
61
62impl ToolSpec {
63    pub fn new(
64        name: impl Into<String>,
65        group: &'static str,
66        doc: impl Into<String>,
67        input_schema: Value,
68        annotations: Annotations,
69        handler: impl Fn(Value) -> ToolFuture + Send + Sync + 'static,
70    ) -> Self {
71        Self {
72            name: name.into(),
73            group,
74            doc: doc.into(),
75            input_schema,
76            annotations,
77            handler: Arc::new(handler),
78        }
79    }
80}
81
82impl std::fmt::Debug for ToolSpec {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.debug_struct("ToolSpec")
85            .field("name", &self.name)
86            .field("group", &self.group)
87            .finish()
88    }
89}
90
91/// The tools a session currently exposes. Rebuilt whenever a registry changes;
92/// the server announces `tools/list_changed` when it does.
93#[derive(Debug, Default)]
94pub struct ToolSet {
95    pub specs: Vec<ToolSpec>,
96}
97
98impl ToolSet {
99    pub fn new(specs: Vec<ToolSpec>) -> Self {
100        Self { specs }
101    }
102    pub fn extend(&mut self, more: Vec<ToolSpec>) {
103        self.specs.extend(more);
104    }
105    pub fn get(&self, name: &str) -> Option<&ToolSpec> {
106        self.specs.iter().find(|s| s.name == name)
107    }
108    pub fn names(&self) -> Vec<&str> {
109        self.specs.iter().map(|s| s.name.as_str()).collect()
110    }
111}
112
113/// `{"type":"object","properties":…,"required":[…],"additionalProperties":false}`.
114pub fn object_schema(properties: Value, required: &[&str]) -> Value {
115    json!({
116        "type": "object",
117        "properties": properties,
118        "required": required,
119        "additionalProperties": false,
120    })
121}
122
123fn arg_str(args: &Value, key: &str) -> Result<String, String> {
124    args.get(key)
125        .and_then(Value::as_str)
126        .map(str::to_string)
127        .ok_or_else(|| format!("missing string argument `{key}`"))
128}
129
130/// Tools that need no app session: they read the kernel's feature catalogue
131/// directly. Everything else arrives through the app's command registry.
132pub fn engine_tools() -> Vec<ToolSpec> {
133    vec![
134        ToolSpec::new(
135            "feature_catalogue",
136            "features",
137            "List every feature the kernel catalogue exposes: type, shortName, longName, displayBuilder. \
138             Read `brep://schema/features/{type}` or call `feature_schema` for a feature's parameter schema.",
139            object_schema(json!({}), &[]),
140            Annotations::READ,
141            |_args| {
142                Box::pin(async move {
143                    let features: Vec<Value> = schema::entries()
144                        .iter()
145                        .map(|e| {
146                            let id = schema::identity(e);
147                            json!({
148                                "type": id.feature_type,
149                                "shortName": id.short_name,
150                                "longName": id.long_name,
151                                "displayBuilder": id.display_builder,
152                            })
153                        })
154                        .collect();
155                    Ok(ToolOutput::json(json!({ "features": features })))
156                })
157            },
158        ),
159        ToolSpec::new(
160            "feature_schema",
161            "features",
162            "The JSON Schema of one feature's inputParams (derived from the kernel's inputParamsSchema) \
163             and its default parameter values. `type` is the catalogue type or shortName, e.g. `E`, `P.CU`, `B`. \
164             A feature that carries a `persistentData` block (the sketch profile Extrude and Revolve consume) also \
165             returns `persistentData` — its schema — and `persistentDataExample`, a complete working block.",
166            object_schema(json!({ "type": { "type": "string", "description": "feature type or shortName" } }), &["type"]),
167            Annotations::READ,
168            |args| {
169                Box::pin(async move {
170                    let ty = arg_str(&args, "type")?;
171                    let entry = schema::entry(&ty).ok_or_else(|| format!("unknown feature type `{ty}`"))?;
172                    let id = schema::identity(&entry);
173                    let mut out = json!({
174                        "type": id.feature_type,
175                        "longName": id.long_name,
176                        "schema": schema::to_json_schema(&entry),
177                        "defaults": schema::defaults(&id.feature_type),
178                    });
179                    // `inputParamsSchema` describes inputParams and nothing else,
180                    // so a sketch's geometry — the part a caller cannot guess —
181                    // has to be published beside it.
182                    if schema::carries_sketch(&id.feature_type) {
183                        out["persistentData"] = schema::sketch_persistent_schema();
184                        out["persistentDataExample"] = schema::sketch_example();
185                    }
186                    Ok(ToolOutput::json(out))
187                })
188            },
189        ),
190    ]
191}
192
193/// Helper for handlers: the arguments as an object map.
194pub fn args_object(args: &Value) -> Map<String, Value> {
195    args.as_object().cloned().unwrap_or_default()
196}
197
198// BREP private tests: 93ae49855c10b3ad