Skip to main content

pmcp_server_toolkit/
resources.rs

1// Originated from pmcp-run/built-in/shared/mcp-server-common/src/resources.rs
2// (https://github.com/guyernest/pmcp-run). Lifted into rust-mcp-sdk for Phase 83.
3
4//! Static MCP resources for config-driven servers.
5//!
6//! [`StaticResourceHandler`] implements [`pmcp::server::ResourceHandler`] over an
7//! in-memory [`IndexMap`] of [`LoadedResource`] entries. The handler does NOT
8//! redefine the trait — it consumes the trait shape from `pmcp`.
9//!
10//! # Wire shape — MIME-typed-wire (PATTERNS §5)
11//!
12//! `read()` returns content via [`Content::resource_with_text`] (NOT
13//! `Content::text`) so per-resource MIME types survive the JSON-RPC wire
14//! round-trip. Reference files like `schema.graphql` keep their
15//! `application/graphql` MIME type rather than being downgraded to
16//! `text/plain`.
17//!
18//! # Determinism (Pattern D)
19//!
20//! Storage is `IndexMap<String, LoadedResource>` (NOT `HashMap`). This
21//! guarantees that `list()` returns resources in deterministic, configuration
22//! order — required for snapshot tests, stable example output, and predictable
23//! host UX.
24//!
25//! # Orthogonality with skills
26//!
27//! `StaticResourceHandler` is independent of [`pmcp::server::skills::Skill`]
28//! and `bootstrap_skill_and_prompt`. Downstream consumers can register both
29//! surfaces side-by-side; the toolkit makes no assumption about skill
30//! registration (RESEARCH §Risks #3).
31//!
32//! # Example configuration
33//!
34//! ```toml
35//! [[resources]]
36//! uri = "docs://policies/guide"
37//! name = "Policy Guide"
38//! description = "How to interpret policies"
39//! mime_type = "text/markdown"
40//! content = """
41//! # Policy Guide
42//! This document explains...
43//! """
44//! ```
45
46use async_trait::async_trait;
47use indexmap::IndexMap;
48use pmcp::{
49    types::{Content, ListResourcesResult, ReadResourceResult, ResourceInfo},
50    ResourceHandler,
51};
52use serde::{Deserialize, Serialize};
53
54use crate::error::{Result, ToolkitError};
55
56// =============================================================================
57// Configuration Types
58// =============================================================================
59
60/// MCP Resource configuration.
61///
62/// Resources provide documentation and context that LLMs can access to better
63/// understand how to use the server's tools. Resources are loaded at build
64/// time and served via MCP `resources/list` and `resources/read`.
65#[derive(Debug, Clone, Deserialize, Serialize)]
66pub struct ResourceConfig {
67    /// Resource URI (e.g., `docs://policies/alcohol-shipment`).
68    pub uri: String,
69
70    /// Human-readable name.
71    pub name: String,
72
73    /// Description of what this resource contains.
74    #[serde(default)]
75    pub description: Option<String>,
76
77    /// MIME type (defaults to `text/markdown`).
78    #[serde(default = "default_mime_type")]
79    pub mime_type: String,
80
81    /// Inline content (mutually exclusive with `content_file`).
82    #[serde(default)]
83    pub content: Option<String>,
84
85    /// Path to content file, relative to config (mutually exclusive with
86    /// `content`).
87    ///
88    /// Not supported in Lambda; use inline content instead.
89    #[serde(default)]
90    pub content_file: Option<String>,
91
92    /// Optional metadata map for resource `_meta` (e.g., widget metadata for
93    /// MCP Apps).
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub meta: Option<serde_json::Map<String, serde_json::Value>>,
96}
97
98fn default_mime_type() -> String {
99    "text/markdown".to_string()
100}
101
102impl ResourceConfig {
103    /// Validate the resource configuration.
104    ///
105    /// Returns [`ToolkitError::Synth`] if neither `content` nor `content_file`
106    /// is set, or if both are set.
107    pub fn validate(&self) -> Result<()> {
108        if self.content.is_none() && self.content_file.is_none() {
109            return Err(ToolkitError::Synth(format!(
110                "Resource '{}': must specify either 'content' or 'content_file'",
111                self.uri
112            )));
113        }
114        if self.content.is_some() && self.content_file.is_some() {
115            return Err(ToolkitError::Synth(format!(
116                "Resource '{}': cannot specify both 'content' and 'content_file'",
117                self.uri
118            )));
119        }
120        Ok(())
121    }
122}
123
124// =============================================================================
125// Loaded Resource
126// =============================================================================
127
128/// A loaded resource with resolved content.
129#[derive(Debug, Clone)]
130pub struct LoadedResource {
131    /// Resource URI.
132    pub uri: String,
133    /// Human-readable name.
134    pub name: String,
135    /// Optional description.
136    pub description: Option<String>,
137    /// MIME type.
138    pub mime_type: String,
139    /// Resolved content.
140    pub content: String,
141    /// Optional metadata map for resource `_meta`.
142    pub meta: Option<serde_json::Map<String, serde_json::Value>>,
143}
144
145impl LoadedResource {
146    /// Create a `LoadedResource` from config with inline content.
147    ///
148    /// Returns [`ToolkitError::Synth`] if `config.content` is absent —
149    /// `content_file` is not supported in this lift (Lambda runtime
150    /// constraint).
151    pub fn from_config(config: &ResourceConfig) -> Result<Self> {
152        let content = config.content.clone().ok_or_else(|| {
153            ToolkitError::Synth(format!(
154                "Resource '{}': inline 'content' is required (content_file not supported in Lambda)",
155                config.uri
156            ))
157        })?;
158
159        Ok(Self {
160            uri: config.uri.clone(),
161            name: config.name.clone(),
162            description: config.description.clone(),
163            mime_type: config.mime_type.clone(),
164            content,
165            meta: config.meta.clone(),
166        })
167    }
168
169    /// Convert to PMCP SDK [`ResourceInfo`] for listing.
170    pub fn to_resource_info(&self) -> ResourceInfo {
171        let mut info = ResourceInfo::new(&self.uri, &self.name).with_mime_type(&self.mime_type);
172        if let Some(ref desc) = self.description {
173            info = info.with_description(desc);
174        }
175        if let Some(ref meta) = self.meta {
176            info = info.with_meta(meta.clone());
177        }
178        info
179    }
180
181    /// Convert to PMCP SDK [`Content`] for reading.
182    ///
183    /// Always uses the MIME-typed-wire shape [`Content::resource_with_text`]
184    /// (PATTERNS §5) so per-resource MIME types survive the wire round-trip.
185    /// If a downstream consumer needs `_meta` propagation on top of MIME, see
186    /// the threat model `T-83-03-03` mitigation in plan 83-03 — a future
187    /// follow-up may add a `with_meta` variant. The current lift drops `_meta`
188    /// at the read boundary; the resource _meta is exposed through
189    /// `to_resource_info()` for `resources/list` only.
190    pub fn to_content(&self) -> Content {
191        Content::resource_with_text(
192            self.uri.clone(),
193            self.content.clone(),
194            self.mime_type.clone(),
195        )
196    }
197}
198
199// =============================================================================
200// Static Resource Handler
201// =============================================================================
202
203/// Handler for static resources loaded from configuration.
204///
205/// Implements the PMCP SDK [`ResourceHandler`] trait for serving pre-loaded
206/// resources via MCP `resources/list` and `resources/read`. Storage is an
207/// [`IndexMap`] (Pattern D) so iteration order is deterministic across runs.
208///
209/// # Orthogonality with skills
210///
211/// `StaticResourceHandler` is independent of [`pmcp::server::skills::Skill`]
212/// and `bootstrap_skill_and_prompt`. Downstream consumers can register both
213/// surfaces side-by-side; the toolkit makes no assumption about skill
214/// registration (RESEARCH §Risks #3).
215pub struct StaticResourceHandler {
216    // IndexMap — see Pattern D in 83-PATTERNS.md. Insertion order is preserved
217    // across iterations so `list()` is deterministic.
218    resources: IndexMap<String, LoadedResource>,
219}
220
221impl StaticResourceHandler {
222    /// Create a handler from a pre-built [`IndexMap`].
223    ///
224    /// This is the constructor Plan 08 will target from
225    /// `impl From<&ServerConfig> for StaticResourceHandler`.
226    ///
227    /// # Example
228    ///
229    /// ```no_run
230    /// use pmcp_server_toolkit::resources::StaticResourceHandler;
231    /// use indexmap::IndexMap;
232    /// let map = IndexMap::new();
233    /// let handler = StaticResourceHandler::new(map);
234    /// # let _ = handler;
235    /// ```
236    pub fn new(resources: IndexMap<String, LoadedResource>) -> Self {
237        Self { resources }
238    }
239
240    /// Create a new handler from a list of resource configurations.
241    ///
242    /// Insertion order is preserved — `list()` reflects the order configs
243    /// were supplied in.
244    pub fn from_configs(configs: &[ResourceConfig]) -> Result<Self> {
245        let mut resources = IndexMap::with_capacity(configs.len());
246
247        for config in configs {
248            let loaded = LoadedResource::from_config(config)?;
249            resources.insert(loaded.uri.clone(), loaded);
250        }
251
252        Ok(Self { resources })
253    }
254
255    /// Create an empty handler with no resources.
256    pub fn empty() -> Self {
257        Self {
258            resources: IndexMap::new(),
259        }
260    }
261
262    /// Returns `true` if there are no resources.
263    pub fn is_empty(&self) -> bool {
264        self.resources.is_empty()
265    }
266
267    /// Returns the number of resources.
268    pub fn len(&self) -> usize {
269        self.resources.len()
270    }
271
272    /// Get a resource by URI (for use outside of the trait).
273    pub fn get(&self, uri: &str) -> Option<&LoadedResource> {
274        self.resources.get(uri)
275    }
276
277    /// Iterate over resource URIs in deterministic insertion order.
278    pub fn uris(&self) -> impl Iterator<Item = &str> {
279        self.resources.keys().map(String::as_str)
280    }
281}
282
283// =============================================================================
284// Construction from `ServerConfig` (Plan 08 — TKIT-04 completion)
285// =============================================================================
286//
287// `ResourceDecl` is the strict, lifted shape parsed by `ServerConfig`
288// (`config::ResourceDecl`), whereas this module's own `ResourceConfig` carries
289// the richer fields (`content_file`, `meta`) used for file-backed and widget
290// resources. The two shapes are NOT identical — `From<&ServerConfig>` maps the
291// configured `[[resources]]` block onto `LoadedResource` directly so callers
292// don't have to thread a second config type through their builders.
293
294impl From<&crate::config::ServerConfig> for StaticResourceHandler {
295    /// Build a [`StaticResourceHandler`] from a parsed [`crate::config::ServerConfig`].
296    ///
297    /// Each `[[resources]]` entry in `config` becomes one [`LoadedResource`].
298    /// Resources with no `content` field default to an empty body — the
299    /// strict-parse path's [`crate::config::ServerConfig::validate`] does not
300    /// flag empty resource bodies (operators may use the placeholder form
301    /// `"loaded from path.md"` as a stable URI handle), so this construction
302    /// follows suit. Resources WITH `content_file` semantics are out of scope
303    /// for the lifted shape (Lambda runtime constraint, mirroring
304    /// [`LoadedResource::from_config`]).
305    ///
306    /// Insertion order matches the order of `[[resources]]` declarations,
307    /// satisfying Pattern D (deterministic `list()` output).
308    ///
309    /// # Example
310    ///
311    /// ```no_run
312    /// use pmcp_server_toolkit::{ServerConfig, StaticResourceHandler};
313    ///
314    /// let cfg = ServerConfig::default();
315    /// let handler = StaticResourceHandler::from(&cfg);
316    /// assert_eq!(handler.len(), 0); // default config has no [[resources]]
317    /// ```
318    fn from(cfg: &crate::config::ServerConfig) -> Self {
319        let mut resources = IndexMap::with_capacity(cfg.resources.len());
320        for r in &cfg.resources {
321            let mime = r.mime_type.clone().unwrap_or_else(default_mime_type);
322            let loaded = LoadedResource {
323                uri: r.uri.clone(),
324                name: r.name.clone().unwrap_or_else(|| r.uri.clone()),
325                description: r.description.clone(),
326                mime_type: mime,
327                content: r.content.clone().unwrap_or_default(),
328                meta: None,
329            };
330            resources.insert(r.uri.clone(), loaded);
331        }
332        Self { resources }
333    }
334}
335
336#[async_trait]
337impl ResourceHandler for StaticResourceHandler {
338    async fn list(
339        &self,
340        _cursor: Option<String>,
341        _extra: pmcp::RequestHandlerExtra,
342    ) -> pmcp::Result<ListResourcesResult> {
343        let resources: Vec<ResourceInfo> = self
344            .resources
345            .values()
346            .map(LoadedResource::to_resource_info)
347            .collect();
348
349        Ok(ListResourcesResult::new(resources))
350    }
351
352    async fn read(
353        &self,
354        uri: &str,
355        _extra: pmcp::RequestHandlerExtra,
356    ) -> pmcp::Result<ReadResourceResult> {
357        match self.resources.get(uri) {
358            Some(resource) => Ok(ReadResourceResult::new(vec![resource.to_content()])),
359            None => Err(pmcp::Error::protocol(
360                pmcp::ErrorCode::METHOD_NOT_FOUND,
361                format!("Resource not found: {}", uri),
362            )),
363        }
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use pmcp::RequestHandlerExtra;
371
372    fn mk_extra() -> RequestHandlerExtra {
373        RequestHandlerExtra::default()
374    }
375
376    fn cfg(uri: &str, mime: &str, body: &str) -> ResourceConfig {
377        ResourceConfig {
378            uri: uri.to_string(),
379            name: uri.to_string(),
380            description: None,
381            mime_type: mime.to_string(),
382            content: Some(body.to_string()),
383            content_file: None,
384            meta: None,
385        }
386    }
387
388    #[test]
389    fn resource_config_validation() {
390        // Valid inline content.
391        let c = cfg("docs://test", "text/plain", "hello");
392        assert!(c.validate().is_ok());
393
394        // Missing content.
395        let mut c = cfg("docs://test", "text/plain", "");
396        c.content = None;
397        assert!(c.validate().is_err());
398
399        // Both content and content_file.
400        let mut c = cfg("docs://test", "text/plain", "hello");
401        c.content_file = Some("file.md".to_string());
402        assert!(c.validate().is_err());
403    }
404
405    #[test]
406    fn loaded_resource_from_config() {
407        let c = cfg("docs://test", "text/markdown", "# Hello\nWorld");
408        let loaded = LoadedResource::from_config(&c).unwrap();
409        assert_eq!(loaded.uri, "docs://test");
410        assert_eq!(loaded.mime_type, "text/markdown");
411        assert_eq!(loaded.content, "# Hello\nWorld");
412    }
413
414    /// Requirement: `read()` returns the MIME-typed-wire resource variant
415    /// (NOT a bare text payload) so per-resource MIME types survive the wire
416    /// round-trip (PATTERNS §5 MIME-typed-wire).
417    #[tokio::test]
418    async fn read_returns_resource_with_text_and_correct_mime() {
419        let handler = StaticResourceHandler::from_configs(&[cfg(
420            "schema://main",
421            "application/graphql",
422            "type Query { hello: String }",
423        )])
424        .unwrap();
425
426        let result = handler.read("schema://main", mk_extra()).await.unwrap();
427        assert_eq!(result.contents.len(), 1);
428        match &result.contents[0] {
429            Content::Resource {
430                uri,
431                text,
432                mime_type,
433                ..
434            } => {
435                assert_eq!(uri, "schema://main");
436                assert_eq!(text.as_deref(), Some("type Query { hello: String }"));
437                assert_eq!(mime_type.as_deref(), Some("application/graphql"));
438            },
439            other => panic!("expected Content::Resource, got {:?}", other),
440        }
441    }
442
443    /// Requirement: `read()` on a missing URI returns `Err`.
444    #[tokio::test]
445    async fn read_missing_uri_returns_err() {
446        let handler = StaticResourceHandler::empty();
447        let result = handler.read("docs://nope", mk_extra()).await;
448        assert!(result.is_err());
449    }
450
451    /// Requirement: `list()` returns resources in deterministic insertion
452    /// order across multiple invocations (Pattern D — IndexMap, not HashMap).
453    #[tokio::test]
454    async fn list_returns_deterministic_order() {
455        let handler = StaticResourceHandler::from_configs(&[
456            cfg("docs://a", "text/plain", "A"),
457            cfg("docs://b", "text/plain", "B"),
458            cfg("docs://c", "text/plain", "C"),
459        ])
460        .unwrap();
461
462        let first = handler.list(None, mk_extra()).await.unwrap();
463        let second = handler.list(None, mk_extra()).await.unwrap();
464
465        let uris1: Vec<&str> = first.resources.iter().map(|r| r.uri.as_str()).collect();
466        let uris2: Vec<&str> = second.resources.iter().map(|r| r.uri.as_str()).collect();
467
468        assert_eq!(uris1, vec!["docs://a", "docs://b", "docs://c"]);
469        assert_eq!(uris1, uris2);
470    }
471
472    #[test]
473    fn handler_len_and_empty() {
474        let handler = StaticResourceHandler::from_configs(&[
475            cfg("docs://one", "text/plain", "Content one"),
476            cfg("docs://two", "text/plain", "Content two"),
477        ])
478        .unwrap();
479        assert_eq!(handler.len(), 2);
480        assert!(!handler.is_empty());
481        assert!(handler.get("docs://one").is_some());
482        assert!(handler.get("docs://three").is_none());
483
484        let uris: Vec<&str> = handler.uris().collect();
485        assert_eq!(uris, vec!["docs://one", "docs://two"]);
486    }
487}