Skip to main content

onshape_mcp_resources/
lib.rs

1//! MCP resource definitions for Onshape integration.
2//!
3//! This crate provides MCP resources generated at compile time from markdown
4//! documentation in `docs/src/mcp-resources/`. Each subdirectory with an
5//! `index.md` becomes a resource group, and each entry in the index becomes
6//! an MCP resource.
7//!
8//! ## Adding resources
9//!
10//! To add a new resource to an existing group (e.g. `insights`):
11//! 1. Create `docs/src/mcp-resources/insights/new-topic.md`
12//! 2. Add an entry to `docs/src/mcp-resources/insights/index.md`:
13//!    `- [New Topic](new-topic.md) — Description of the new topic`
14//!
15//! To add a new resource group:
16//! 1. Create `docs/src/mcp-resources/new-group/index.md`
17//! 2. Add entries following the same format
18//!
19//! No Rust code changes are needed in either case.
20
21use rmcp::model::{
22    AnnotateAble, Annotated, RawResource, ReadResourceResult, ResourceContents, Role,
23};
24
25// ============================================================================
26// Resource Entry (compile-time data structure)
27// ============================================================================
28
29/// A single resource entry, populated at compile time by the build script.
30pub struct ResourceEntry {
31    /// The resource group (e.g. `"insights"`). Used as the URI scheme.
32    pub group: &'static str,
33    /// The resource name derived from the filename stem (e.g. `"shaded-views"`).
34    pub name: &'static str,
35    /// Human-readable title (e.g. `"Shaded Views"`).
36    pub title: &'static str,
37    /// Brief description for resource listings.
38    pub description: &'static str,
39    /// The full URI (e.g. `"insights:shaded-views"`).
40    pub uri: &'static str,
41    /// The full markdown content of the resource.
42    pub content: &'static str,
43}
44
45// Include the generated resource catalog.
46// Generated code uses raw strings with uniform hashing for simplicity.
47#[allow(clippy::needless_raw_string_hashes)]
48mod generated {
49    use super::ResourceEntry;
50    include!(concat!(env!("OUT_DIR"), "/resources_generated.rs"));
51}
52pub use generated::RESOURCES;
53
54// ============================================================================
55// Effect Type
56// ============================================================================
57
58/// Result of dispatching a resource operation.
59///
60/// Mirrors the [`ToolEffect`](../onshape_mcp_core/tools/enum.ToolEffect.html)
61/// pattern. Currently all resources are static, so only `Immediate` is used.
62/// The enum exists to establish a common effects-as-data pattern that can be
63/// extended if resources ever need I/O.
64pub enum ResourceResult {
65    /// Resource operation completed immediately with no I/O needed.
66    Immediate(Result<ReadResourceResult, ResourceError>),
67}
68
69/// Errors that can occur when reading a resource.
70#[derive(Debug)]
71pub enum ResourceError {
72    /// The requested URI does not match any known resource.
73    NotFound(String),
74}
75
76// ============================================================================
77// Public API
78// ============================================================================
79
80/// List all available MCP resources.
81///
82/// Returns resource metadata (URI, name, title, description) with annotations
83/// marking them as intended for the assistant (LLM) with moderately high
84/// priority.
85#[must_use]
86pub fn list_resources() -> Vec<Annotated<RawResource>> {
87    RESOURCES
88        .iter()
89        .map(|entry| {
90            RawResource::new(entry.uri, entry.name)
91                .with_title(entry.title)
92                .with_description(entry.description)
93                .with_mime_type("text/markdown")
94                .with_size(u32::try_from(entry.content.len()).unwrap_or(u32::MAX))
95                .no_annotation()
96                .with_audience(vec![Role::Assistant])
97                .with_priority(0.8)
98        })
99        .collect()
100}
101
102/// Read a specific resource by URI.
103///
104/// Returns the markdown content for the matching resource, or an error if
105/// the URI is not recognized.
106#[must_use]
107pub fn read_resource(uri: &str) -> ResourceResult {
108    let result = RESOURCES
109        .iter()
110        .find(|entry| entry.uri == uri)
111        .map(|entry| {
112            ReadResourceResult::new(vec![
113                ResourceContents::text(entry.content, entry.uri).with_mime_type("text/markdown"),
114            ])
115        })
116        .ok_or_else(|| ResourceError::NotFound(uri.into()));
117
118    ResourceResult::Immediate(result)
119}
120
121// ============================================================================
122// Tests
123// ============================================================================
124
125#[cfg(test)]
126#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn generated_resources_are_not_empty() {
132        assert!(
133            !RESOURCES.is_empty(),
134            "build.rs should generate at least one resource"
135        );
136    }
137
138    #[test]
139    fn all_entries_have_required_fields() {
140        for entry in RESOURCES {
141            assert!(!entry.group.is_empty(), "group should not be empty");
142            assert!(!entry.name.is_empty(), "name should not be empty");
143            assert!(!entry.title.is_empty(), "title should not be empty");
144            assert!(
145                !entry.description.is_empty(),
146                "description should not be empty for {}",
147                entry.name
148            );
149            assert!(!entry.uri.is_empty(), "uri should not be empty");
150            assert!(
151                !entry.content.is_empty(),
152                "content should not be empty for {}",
153                entry.name
154            );
155        }
156    }
157
158    #[test]
159    fn uri_format_matches_group_colon_name() {
160        for entry in RESOURCES {
161            let expected = format!("{}:{}", entry.group, entry.name);
162            assert_eq!(
163                entry.uri, expected,
164                "URI should be group:name for {}",
165                entry.name
166            );
167        }
168    }
169
170    #[test]
171    fn list_resources_returns_all_entries() {
172        let resources = list_resources();
173        assert_eq!(resources.len(), RESOURCES.len());
174    }
175
176    #[test]
177    fn list_resources_sets_annotations() {
178        let resources = list_resources();
179        for resource in &resources {
180            let annotations = resource
181                .annotations
182                .as_ref()
183                .expect("annotations should be set");
184            assert_eq!(
185                annotations.audience.as_deref(),
186                Some(&[Role::Assistant][..])
187            );
188            assert_eq!(annotations.priority, Some(0.8));
189        }
190    }
191
192    #[test]
193    fn list_resources_sets_mime_type() {
194        let resources = list_resources();
195        for resource in &resources {
196            assert_eq!(resource.raw.mime_type.as_deref(), Some("text/markdown"));
197        }
198    }
199
200    #[test]
201    fn read_resource_returns_content_for_valid_uri() {
202        for entry in RESOURCES {
203            let result = read_resource(entry.uri);
204            let ResourceResult::Immediate(Ok(read_result)) = result else {
205                panic!("expected Immediate(Ok(...)) for URI {}", entry.uri);
206            };
207            assert_eq!(read_result.contents.len(), 1);
208            match &read_result.contents[0] {
209                ResourceContents::TextResourceContents { uri, text, .. } => {
210                    assert_eq!(uri, entry.uri);
211                    assert_eq!(text, entry.content);
212                }
213                ResourceContents::BlobResourceContents { .. } => {
214                    panic!("expected text content, got blob");
215                }
216            }
217        }
218    }
219
220    #[test]
221    fn read_resource_returns_error_for_unknown_uri() {
222        let result = read_resource("nonexistent:nothing");
223        let ResourceResult::Immediate(Err(ResourceError::NotFound(uri))) = result else {
224            panic!("expected Immediate(Err(NotFound(...)))");
225        };
226        assert_eq!(uri, "nonexistent:nothing");
227    }
228
229    #[test]
230    fn insights_group_exists() {
231        let has_insights = RESOURCES.iter().any(|e| e.group == "insights");
232        assert!(has_insights, "should have at least one insights resource");
233    }
234
235    #[test]
236    fn known_insight_resources_present() {
237        let names: Vec<&str> = RESOURCES
238            .iter()
239            .filter(|e| e.group == "insights")
240            .map(|e| e.name)
241            .collect();
242        assert!(
243            names.contains(&"shaded-views"),
244            "should have shaded-views insight"
245        );
246        assert!(names.contains(&"sketch"), "should have sketch insight");
247    }
248}