Skip to main content

teamcraft/mcp/tools/
preview_tools.rs

1//! `open_3d_preview`: finds the item the user means and the file of its
2//! revision that the browser's 3D viewer can show, and answers with the
3//! viewer page and an action hint (`{"action":"open","kind":"preview3d",
4//! "url":…}`). MCP is stateless, so the tool opens nothing itself: an MCP
5//! client shows the link, the built-in assistant a button (see
6//! `crate::assistant::open_links`). Only reads, like the other read tools
7//! without an audit record.
8
9use uuid::Uuid;
10
11use super::*;
12use crate::preview::PreviewFormat;
13
14/// Most candidates listed when the search text matches several items.
15pub const MAX_PREVIEW_CANDIDATES: usize = 8;
16/// Other 3D files of the revision named next to the chosen one.
17const MAX_OTHER_FILES: usize = 20;
18/// Files named when the revision has no 3D file.
19const MAX_LISTED_FILES: usize = 20;
20
21/// What the arguments ask for.
22#[derive(Debug, PartialEq, Eq)]
23pub(super) enum Target {
24    /// An item number, with the revision code of `PRT-000012/B` or `revision`.
25    Item {
26        number: String,
27        revision: Option<String>,
28    },
29    /// Free text for the item search.
30    Search {
31        query: String,
32        revision: Option<String>,
33    },
34}
35
36/// Reads `item`, `q` and `revision`. `item` wins over `q`; a revision code
37/// in `item` and a different `revision` contradict each other.
38pub(super) fn target(a: &Args<'_>) -> AppResult<Target> {
39    let revision = a.opt_str("revision")?;
40    if let Some(reference) = a.opt_str("item")? {
41        let (number, code) = parse_item_ref(&reference);
42        if number.is_empty() {
43            return Err(bad("`item` must be an item number, e.g. PRT-000012."));
44        }
45        let revision = match (code, revision) {
46            (Some(c), Some(r)) if !c.eq_ignore_ascii_case(&r) => {
47                return Err(bad(format!(
48                    "`item` names revision {c} but `revision` is {r} – give one."
49                )))
50            }
51            (Some(c), _) => Some(c),
52            (None, r) => r,
53        };
54        return Ok(Target::Item { number, revision });
55    }
56    match a.opt_str("q")? {
57        Some(query) => Ok(Target::Search { query, revision }),
58        None => Err(bad(
59            "Give `item` (an item number) or `q` (search text, e.g. a name like `bracket`).",
60        )),
61    }
62}
63
64/// The 3D preview of a stored file: by its stored format, or by its name
65/// while the format is not classified yet (as the revision page decides
66/// whether to show "3D view").
67pub(super) fn preview_format(format_id: &str, filename: &str) -> Option<PreviewFormat> {
68    PreviewFormat::of_format(format_id).or_else(|| PreviewFormat::of(filename))
69}
70
71/// Which 3D file is opened first: models (STEP first, as the shape
72/// comparison does), then simulation results, then point clouds.
73pub(super) fn preview_rank(format: PreviewFormat) -> u8 {
74    if format.is_point_cloud() {
75        3
76    } else if format.is_result() {
77        2
78    } else if format.is_brep() {
79        0
80    } else {
81        1
82    }
83}
84
85/// The 3D files of a revision, the one to open first at the front (ties by
86/// file name).
87fn ranked(files: &[FileRecord]) -> Vec<(&FileRecord, PreviewFormat)> {
88    let mut list: Vec<(&FileRecord, PreviewFormat)> = files
89        .iter()
90        .filter_map(|f| preview_format(&f.format, &f.filename).map(|p| (f, p)))
91        .collect();
92    list.sort_by(|(fa, pa), (fb, pb)| {
93        preview_rank(*pa)
94            .cmp(&preview_rank(*pb))
95            .then_with(|| fa.filename.to_lowercase().cmp(&fb.filename.to_lowercase()))
96    });
97    list
98}
99
100/// The path of the 3D viewer page of a file (the current version).
101pub fn viewer_path(file_id: Uuid) -> String {
102    format!("/files/{file_id}/preview")
103}
104
105/// The action hint of a viewer URL.
106pub fn preview_action(url: &str) -> Value {
107    json!({ "action": "open", "kind": "preview3d", "url": url })
108}
109
110/// Checks a viewer URL from a tool result before anything shows it: it must
111/// be this server's 3D viewer page of a file – a relative `/files/<uuid>/preview`
112/// or the same under `public_url` (same scheme, host and port). Returns the
113/// relative path in canonical form; anything else (other hosts, `//host`,
114/// queries, fragments, encodings, other pages) is `None`.
115pub fn checked_viewer_path(url: &str, public_url: &str) -> Option<String> {
116    let url = url.trim();
117    let public_url = public_url.trim().trim_end_matches('/');
118    let path = if url.starts_with('/') {
119        url
120    } else if !public_url.is_empty() {
121        url.strip_prefix(public_url)
122            .filter(|rest| rest.starts_with('/'))?
123    } else {
124        return None;
125    };
126    let id = path.strip_prefix("/files/")?.strip_suffix("/preview")?;
127    // exactly a hyphenated UUID: no slashes, dots, `%`, `?` or `#`
128    if id.len() != 36 || !id.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-') {
129        return None;
130    }
131    let id = Uuid::parse_str(id).ok()?;
132    Some(viewer_path(id))
133}
134
135/// `open_3d_preview`.
136pub(super) async fn open_3d_preview(st: &AppState, me: &Actor, a: &Args<'_>) -> AppResult<Value> {
137    let file_name = a.opt_str("file")?;
138    let rev = match target(a)? {
139        Target::Item { number, revision } => {
140            revision_at(st, me, &number, revision.as_deref()).await?
141        }
142        Target::Search { query, revision } => match search_target(st, me, a, &query).await? {
143            Found::One(number) => revision_at(st, me, &number, revision.as_deref()).await?,
144            Found::Choose(answer) => return Ok(answer),
145        },
146    };
147    let file_list = files::list(&st.db, me, rev.id).await?;
148    let usable = ranked(&file_list);
149    let chosen = match &file_name {
150        Some(name) => {
151            let file = file_list
152                .iter()
153                .find(|f| f.filename.eq_ignore_ascii_case(name))
154                .ok_or_else(|| {
155                    AppError::not_found(format!(
156                        "{}/{} has no file '{name}'.",
157                        rev.item_number, rev.code
158                    ))
159                })?;
160            let format = preview_format(&file.format, &file.filename).ok_or_else(|| {
161                bad(format!(
162                    "'{}' has no 3D preview. Viewable are STEP, STL, OBJ, PLY, 3MF, glTF, JT, NX and \
163                     SOLIDWORKS parts, Rhino, point clouds and VTK/FRD results.",
164                    file.filename
165                ))
166            })?;
167            Some((file, format))
168        }
169        None => usable.first().copied(),
170    };
171    let Some((file, format)) = chosen else {
172        let (listed, cut) = capped(file_list.iter().collect::<Vec<_>>(), MAX_LISTED_FILES);
173        return Ok(json!({
174            "status": "no_3d_file",
175            "item": rev.item_number,
176            "revision": rev.code,
177            "name": rev.name,
178            "message": format!(
179                "{}/{} has no file the 3D viewer can show. Native CAD files without a stored \
180                 lightweight model need a neutral export (STEP, STL, 3MF …) in the revision. Tell \
181                 the user; do not open anything.",
182                rev.item_number, rev.code
183            ),
184            "files": listed.iter().map(|f| json!({
185                "filename": f.filename,
186                "format": f.format_info.as_ref().map(|i| i.label),
187            })).collect::<Vec<_>>(),
188            "truncated": cut,
189            "revision_url": web_url(st, &format!("/revisions/{}", rev.id)),
190        }));
191    };
192    let url = web_url(st, &viewer_path(file.id));
193    let (others, others_cut) = capped(
194        usable
195            .iter()
196            .filter(|(f, _)| f.id != file.id)
197            .map(|(f, p)| json!({ "filename": f.filename, "format": p.as_str() }))
198            .collect::<Vec<_>>(),
199        MAX_OTHER_FILES,
200    );
201    Ok(json!({
202        "status": "ok",
203        "item": rev.item_number,
204        "revision": rev.code,
205        "name": rev.name,
206        "state": rev.state,
207        "file": {
208            "filename": file.filename,
209            "format": format.as_str(),
210            "version": file.version,
211        },
212        "url": url,
213        "action": preview_action(&url),
214        "other_3d_files": others,
215        "truncated": others_cut,
216        "revision_url": web_url(st, &format!("/revisions/{}", rev.id)),
217        "message": "Give the user this link to the 3D viewer (TeamCraft's own assistant shows it as a \
218            button). Another file: call again with `file`.",
219    }))
220}
221
222enum Found {
223    One(String),
224    Choose(Value),
225}
226
227/// The item a search text means: the only hit, or the hit whose number is
228/// the text; several hits are returned as candidates to choose from.
229async fn search_target(st: &AppState, me: &Actor, a: &Args<'_>, query: &str) -> AppResult<Found> {
230    let project_id = match a.opt_str("project")? {
231        Some(code) => Some(projects::get_by_code(&st.db, me, &code).await?.id),
232        None => None,
233    };
234    let filter = items::ItemFilter {
235        query: Some(query.to_string()),
236        project_id,
237        ..Default::default()
238    };
239    let (rows, total) = items::search(
240        &st.db,
241        me,
242        &filter,
243        Page::new(Some(MAX_PREVIEW_CANDIDATES as i64), None),
244    )
245    .await?;
246    if let Some(exact) = rows
247        .iter()
248        .find(|r| r.number.eq_ignore_ascii_case(query.trim()))
249    {
250        return Ok(Found::One(exact.number.clone()));
251    }
252    match rows.as_slice() {
253        [] => Err(AppError::not_found(format!(
254            "No item matches '{query}'. Try a shorter search text (a key word of the name, e.g. \
255             `bracket`) or search_items."
256        ))),
257        [only] if total == 1 => Ok(Found::One(only.number.clone())),
258        _ => {
259            let mut candidates = Vec::with_capacity(rows.len());
260            for r in &rows {
261                let file_list = files::list(&st.db, me, r.latest_revision_id).await?;
262                let first = ranked(&file_list).first().map(|(f, _)| f.filename.clone());
263                candidates.push(json!({
264                    "item": r.number,
265                    "name": r.name,
266                    "type": r.item_type,
267                    "project": r.project_code,
268                    "latest_revision": r.latest_code,
269                    "latest_state": r.latest_state,
270                    "has_3d": first.is_some(),
271                    "3d_file": first,
272                }));
273            }
274            Ok(Found::Choose(json!({
275                "status": "choose",
276                "query": query,
277                "message": format!(
278                    "{total} items match '{query}'. Do not guess: ask the user which one they mean \
279                     (or narrow the search), then call again with `item`."
280                ),
281                "total": total,
282                "truncated": total > rows.len() as i64,
283                "candidates": candidates,
284            })))
285        }
286    }
287}