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}