1use std::{
7 collections::BTreeMap,
8 path::{Path, PathBuf},
9};
10
11use async_trait::async_trait;
12use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
13use serde_json::Value;
14use soma_provider_core::{
15 ProviderCatalog, ProviderIdentity, ProviderKind, ProviderManifest, ProviderResource,
16};
17
18use soma_provider_adapters::{error::SidecarError, sidecar::run_bounded_sidecar};
19
20use crate::{
21 provider_errors::ProviderError,
22 provider_registry::{
23 DynamicResourceTemplate, Provider, ProviderCall, ProviderOutput, ResourceReadOutput,
24 },
25 providers::resource_uri,
26};
27
28pub const MAX_STATIC_RESOURCE_BYTES: u64 = 10 * 1024 * 1024;
31
32const DYNAMIC_RESOURCE_TIMEOUT_MS: u64 = 10_000;
33const DYNAMIC_RESOURCE_MAX_OUTPUT_BYTES: usize = 1024 * 1024;
34
35#[derive(Debug, Clone)]
36enum ResourceFileKind {
37 Static {
38 path: PathBuf,
39 resource: Box<ProviderResource>,
40 mime_type: String,
41 },
42 Dynamic {
43 path: PathBuf,
44 template: DynamicResourceTemplate,
45 },
46}
47
48#[derive(Debug, Clone)]
51pub struct ResourceFileProvider {
52 provider_name: String,
53 kind: ResourceFileKind,
54 canonical_root: PathBuf,
59}
60
61impl ResourceFileProvider {
62 pub fn from_file(
70 absolute_path: PathBuf,
71 relative_path: &Path,
72 canonical_root: &Path,
73 ) -> Result<Self, ResourceFileError> {
74 let is_dynamic_reader =
75 relative_path.extension().and_then(|ext| ext.to_str()) == Some("ts");
76
77 let mut stem_segments: Vec<String> = relative_path
78 .components()
79 .map(|component| component.as_os_str().to_string_lossy().into_owned())
80 .collect();
81 if let Some(last) = stem_segments.last_mut()
82 && let Some(stem) = Path::new(last.as_str())
83 .file_stem()
84 .and_then(|stem| stem.to_str())
85 {
86 *last = stem.to_owned();
87 }
88 let segment_refs: Vec<&str> = stem_segments.iter().map(String::as_str).collect();
89 let resource_path = resource_uri::parse_resource_path(&segment_refs)
90 .map_err(|error| ResourceFileError(error.0))?;
91 let clean_name = joined_segment_name(&resource_path);
96
97 let provider_name = format!("resource-{clean_name}");
98
99 if is_dynamic_reader {
100 let description = format!(
101 "Dynamic resource reader from {}",
102 resource_uri::display_with_forward_slashes(relative_path)
103 );
104 let mut template = DynamicResourceTemplate::from_path_segments(
105 &segment_refs,
106 clean_name,
107 description,
108 None,
109 )
110 .map_err(ResourceFileError)?;
111 template.scope = Some("soma:write".to_owned());
119 return Ok(Self {
120 provider_name,
121 kind: ResourceFileKind::Dynamic {
122 path: absolute_path,
123 template,
124 },
125 canonical_root: canonical_root.to_owned(),
126 });
127 }
128
129 let metadata = std::fs::metadata(&absolute_path).map_err(|source| {
130 ResourceFileError(format!("failed to read resource file metadata: {source}"))
131 })?;
132 if metadata.len() > MAX_STATIC_RESOURCE_BYTES {
133 return Err(ResourceFileError(format!(
134 "resource file exceeds the {MAX_STATIC_RESOURCE_BYTES}-byte static resource limit"
135 )));
136 }
137 if resource_path.is_dynamic() {
138 return Err(ResourceFileError(
139 "static resource files (non-.ts) cannot use bracketed [param] path segments"
140 .to_owned(),
141 ));
142 }
143
144 let mime_type = mime_type_for_extension(relative_path);
145 let name = clean_name;
152 let description = static_resource_description(&absolute_path, &mime_type, &name)?;
153 let resource = ProviderResource {
154 uri_template: resource_path.uri_string(),
155 name,
156 description,
157 mime_type: Some(mime_type.clone()),
158 scope: None,
159 mcp: None,
160 annotations: serde_json::json!({}),
161 };
162 Ok(Self {
163 provider_name,
164 kind: ResourceFileKind::Static {
165 path: absolute_path,
166 resource: Box::new(resource),
167 mime_type,
168 },
169 canonical_root: canonical_root.to_owned(),
170 })
171 }
172
173 pub fn arc(
176 absolute_path: PathBuf,
177 relative_path: &Path,
178 canonical_root: &Path,
179 ) -> Result<std::sync::Arc<dyn Provider>, ResourceFileError> {
180 Ok(std::sync::Arc::new(Self::from_file(
181 absolute_path,
182 relative_path,
183 canonical_root,
184 )?))
185 }
186}
187
188#[derive(Debug)]
190pub struct ResourceFileError(
191 pub String,
193);
194
195impl std::fmt::Display for ResourceFileError {
196 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197 write!(f, "{}", self.0)
198 }
199}
200
201impl std::error::Error for ResourceFileError {}
202
203fn joined_segment_name(path: &resource_uri::ResourcePath) -> String {
213 path.segments
214 .iter()
215 .map(|segment| match segment {
216 resource_uri::PathSegment::Literal(value) => value.clone(),
217 resource_uri::PathSegment::Param(name) | resource_uri::PathSegment::CatchAll(name) => {
218 name.clone()
219 }
220 })
221 .collect::<Vec<_>>()
222 .join("_")
223}
224
225fn static_resource_description(
226 path: &Path,
227 mime_type: &str,
228 name: &str,
229) -> Result<String, ResourceFileError> {
230 if mime_type == "text/markdown" {
231 let text = std::fs::read_to_string(path).map_err(|source| {
232 ResourceFileError(format!("failed to read resource file: {source}"))
233 })?;
234 if let Some(heading) = first_markdown_heading(&text) {
235 return Ok(heading);
236 }
237 }
238 Ok(format!("Resource `{name}`"))
239}
240
241fn first_markdown_heading(text: &str) -> Option<String> {
242 text.lines().find_map(|line| {
243 let trimmed = line.trim();
244 let heading = trimmed.strip_prefix("# ")?.trim();
245 (!heading.is_empty()).then(|| heading.to_owned())
246 })
247}
248
249fn mime_type_for_extension(path: &Path) -> String {
252 let extension = path
253 .extension()
254 .and_then(|extension| extension.to_str())
255 .unwrap_or_default()
256 .to_ascii_lowercase();
257 match extension.as_str() {
258 "md" | "markdown" => "text/markdown",
259 "txt" => "text/plain",
260 "json" => "application/json",
261 "yaml" | "yml" => "application/yaml",
262 "toml" => "application/toml",
263 "html" | "htm" => "text/html",
264 "css" => "text/css",
265 "js" | "mjs" => "text/javascript",
266 "csv" => "text/csv",
267 "xml" => "application/xml",
268 "svg" => "image/svg+xml",
269 "png" => "image/png",
270 "jpg" | "jpeg" => "image/jpeg",
271 "gif" => "image/gif",
272 "webp" => "image/webp",
273 "pdf" => "application/pdf",
274 _ => "application/octet-stream",
275 }
276 .to_owned()
277}
278
279fn is_text_mime(mime_type: &str) -> bool {
280 mime_type.starts_with("text/")
281 || matches!(
282 mime_type,
283 "application/json" | "application/yaml" | "application/toml" | "application/xml"
284 )
285}
286
287#[async_trait]
288impl Provider for ResourceFileProvider {
289 fn catalog(&self) -> ProviderCatalog {
290 let (title, description, resources) = match &self.kind {
291 ResourceFileKind::Static { resource, path, .. } => (
292 resource.description.clone(),
293 format!("Static resource file loaded from {}", path.display()),
294 vec![resource.as_ref().clone()],
295 ),
296 ResourceFileKind::Dynamic { template, path } => (
297 template.description.clone(),
298 format!("Dynamic resource reader loaded from {}", path.display()),
299 Vec::new(),
300 ),
301 };
302 ProviderManifest {
303 schema_version: 1,
304 provider: ProviderIdentity {
305 name: self.provider_name.clone(),
306 kind: ProviderKind::StaticRust,
307 title: Some(title),
308 description: Some(description),
309 homepage: None,
310 source: Some(self.source_path().display().to_string()),
311 version: None,
312 enabled: None,
313 },
314 tools: Vec::new(),
315 prompts: Vec::new(),
316 resources,
317 tasks: Vec::new(),
318 elicitation: Vec::new(),
319 env: Vec::new(),
320 capabilities: Default::default(),
321 docs: None,
322 plugin: None,
323 ui: None,
324 meta: serde_json::json!({}),
325 }
326 }
327
328 async fn call(&self, call: ProviderCall) -> Result<ProviderOutput, ProviderError> {
329 Err(ProviderError::validation(
330 &self.provider_name,
331 &call.action,
332 "resource_provider_has_no_actions",
333 "resource file providers do not expose any callable actions",
334 ))
335 }
336
337 fn dynamic_resource_templates(&self) -> Vec<DynamicResourceTemplate> {
338 match &self.kind {
339 ResourceFileKind::Dynamic { template, .. } => vec![template.clone()],
340 ResourceFileKind::Static { .. } => Vec::new(),
341 }
342 }
343
344 fn supports_resource_reads(&self) -> bool {
345 true
346 }
347
348 async fn read_resource(
349 &self,
350 uri: &str,
351 params: &BTreeMap<String, String>,
352 ) -> Result<ResourceReadOutput, ProviderError> {
353 match &self.kind {
354 ResourceFileKind::Static {
355 path, mime_type, ..
356 } => read_static_resource(
357 &self.provider_name,
358 uri,
359 path,
360 &self.canonical_root,
361 mime_type,
362 ),
363 ResourceFileKind::Dynamic { path, .. } => {
364 read_dynamic_resource(&self.provider_name, uri, path, &self.canonical_root, params)
365 .await
366 }
367 }
368 }
369}
370
371impl ResourceFileProvider {
372 fn source_path(&self) -> &Path {
373 match &self.kind {
374 ResourceFileKind::Static { path, .. } | ResourceFileKind::Dynamic { path, .. } => path,
375 }
376 }
377}
378
379fn verify_within_root(
385 provider_name: &str,
386 uri: &str,
387 path: &Path,
388 canonical_root: &Path,
389) -> Result<PathBuf, ProviderError> {
390 let canonical = path.canonicalize().map_err(|source| {
391 ProviderError::execution(
392 provider_name,
393 uri,
394 format!("resource file unreadable: {source}"),
395 )
396 })?;
397 if !canonical.starts_with(canonical_root) {
398 return Err(ProviderError::validation(
399 provider_name,
400 uri,
401 "resource_escapes_root",
402 "resource path no longer resolves within the provider root",
403 ));
404 }
405 Ok(canonical)
406}
407
408fn read_static_resource(
409 provider_name: &str,
410 uri: &str,
411 path: &Path,
412 canonical_root: &Path,
413 mime_type: &str,
414) -> Result<ResourceReadOutput, ProviderError> {
415 let path = verify_within_root(provider_name, uri, path, canonical_root)?;
416 let path = path.as_path();
417 let metadata = std::fs::metadata(path).map_err(|source| {
418 ProviderError::execution(
419 provider_name,
420 uri,
421 format!("resource file unreadable: {source}"),
422 )
423 })?;
424 if metadata.len() > MAX_STATIC_RESOURCE_BYTES {
425 return Err(ProviderError::validation(
426 provider_name,
427 uri,
428 "resource_too_large",
429 format!("resource file exceeds the {MAX_STATIC_RESOURCE_BYTES}-byte limit"),
430 ));
431 }
432 let bytes = std::fs::read(path).map_err(|source| {
433 ProviderError::execution(
434 provider_name,
435 uri,
436 format!("failed to read resource file: {source}"),
437 )
438 })?;
439 if is_text_mime(mime_type) {
440 let text = String::from_utf8(bytes).map_err(|error| {
441 ProviderError::execution(
442 provider_name,
443 uri,
444 format!("resource file is not valid UTF-8 text: {error}"),
445 )
446 })?;
447 return Ok(ResourceReadOutput::Text {
448 text,
449 mime_type: Some(mime_type.to_owned()),
450 });
451 }
452 Ok(ResourceReadOutput::Blob {
453 blob_base64: BASE64.encode(bytes),
454 mime_type: Some(mime_type.to_owned()),
455 })
456}
457
458async fn read_dynamic_resource(
459 provider_name: &str,
460 uri: &str,
461 path: &Path,
462 canonical_root: &Path,
463 params: &BTreeMap<String, String>,
464) -> Result<ResourceReadOutput, ProviderError> {
465 let canonical_path = verify_within_root(provider_name, uri, path, canonical_root)?;
466 let query = resource_uri::query_params(uri);
467 let input = serde_json::json!({
468 "uri": uri,
469 "params": params,
470 "query": query,
471 });
472 let input_bytes = serde_json::to_vec(&input).map_err(|error| {
473 ProviderError::execution(
474 provider_name,
475 uri,
476 format!("failed to serialize reader input: {error}"),
477 )
478 })?;
479
480 let wrapper = dynamic_reader_wrapper(&canonical_path);
481
482 let sidecar = match run_bounded_sidecar(
483 "node",
484 &["--input-type=module", "--eval", &wrapper],
485 Vec::new(),
486 &input_bytes,
487 DYNAMIC_RESOURCE_TIMEOUT_MS,
488 DYNAMIC_RESOURCE_MAX_OUTPUT_BYTES,
489 )
490 .await
491 {
492 Ok(sidecar) => sidecar,
493 Err(SidecarError::Timeout) => {
494 return Err(ProviderError::new(
495 "resource_reader_timeout",
496 provider_name,
497 Some(uri.to_owned()),
498 format!("dynamic resource reader exceeded {DYNAMIC_RESOURCE_TIMEOUT_MS}ms timeout"),
499 "Fix the reader's performance or reduce the work it does per call.",
500 ));
501 }
502 Err(error) => {
503 return Err(ProviderError::execution(provider_name, uri, error));
504 }
505 };
506
507 if sidecar.stdout_exceeded || sidecar.stderr_exceeded {
508 return Err(ProviderError::validation(
509 provider_name,
510 uri,
511 "resource_reader_output_too_large",
512 format!(
513 "dynamic resource reader output exceeds {DYNAMIC_RESOURCE_MAX_OUTPUT_BYTES} bytes"
514 ),
515 ));
516 }
517 let output = sidecar.output;
518 if !output.status.success() {
519 let stderr = String::from_utf8_lossy(&output.stderr);
520 return Err(ProviderError::new(
521 "resource_reader_failed",
522 provider_name,
523 Some(uri.to_owned()),
524 format!("dynamic resource reader failed: {stderr}"),
525 "Fix the TypeScript resource reader and retry.",
526 ));
527 }
528
529 let value: Value = serde_json::from_slice(&output.stdout).map_err(|error| {
530 ProviderError::validation(
531 provider_name,
532 uri,
533 "resource_reader_invalid_json_output",
534 error.to_string(),
535 )
536 })?;
537 parse_reader_output(provider_name, uri, &value)
538}
539
540fn parse_reader_output(
541 provider_name: &str,
542 uri: &str,
543 value: &Value,
544) -> Result<ResourceReadOutput, ProviderError> {
545 let invalid = |message: &str| {
546 ProviderError::validation(
547 provider_name,
548 uri,
549 "resource_reader_invalid_shape",
550 message.to_owned(),
551 )
552 };
553 let object = value
554 .as_object()
555 .ok_or_else(|| invalid("reader must return an object"))?;
556
557 if let Some(text) = object.get("text") {
558 let text = text
559 .as_str()
560 .ok_or_else(|| invalid("`text` must be a string"))?
561 .to_owned();
562 let mime_type = object
563 .get("mimeType")
564 .and_then(Value::as_str)
565 .map(ToOwned::to_owned);
566 return Ok(ResourceReadOutput::Text { text, mime_type });
567 }
568 if let Some(json_value) = object.get("json") {
569 let text = serde_json::to_string(json_value)
570 .map_err(|error| invalid(&format!("`json` result could not be serialized: {error}")))?;
571 return Ok(ResourceReadOutput::Text {
572 text,
573 mime_type: Some("application/json".to_owned()),
574 });
575 }
576 if let Some(blob) = object.get("blob") {
577 let blob_base64 = blob
578 .as_str()
579 .ok_or_else(|| invalid("`blob` must be a base64 string"))?
580 .to_owned();
581 let mime_type = object
582 .get("mimeType")
583 .and_then(Value::as_str)
584 .ok_or_else(|| invalid("`blob` results require a `mimeType`"))?
585 .to_owned();
586 return Ok(ResourceReadOutput::Blob {
587 blob_base64,
588 mime_type: Some(mime_type),
589 });
590 }
591 Err(invalid(
592 "reader must return one of `{ text }`, `{ json }`, or `{ blob, mimeType }`",
593 ))
594}
595
596fn dynamic_reader_wrapper(canonical_path: &Path) -> String {
604 let module_path = serde_json::to_string(&canonical_path.display().to_string())
605 .expect("String serialization to JSON cannot fail");
606 format!(
607 r#"
608import {{ readFileSync }} from "node:fs";
609const chunks = [];
610for await (const chunk of process.stdin) chunks.push(chunk);
611const input = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{{}}");
612for (const key of Object.keys(process.env)) {{
613 delete process.env[key];
614}}
615const readerSource = readFileSync({module_path}, "utf8");
616const module = await import("data:text/javascript;base64," + Buffer.from(readerSource).toString("base64"));
617const handler = module.read;
618if (typeof handler !== "function") {{
619 throw new Error("Dynamic resource reader must export async function read(input)");
620}}
621const result = await handler(input);
622process.stdout.write(JSON.stringify(result ?? null));
623"#
624 )
625}
626
627#[cfg(test)]
628#[path = "resource_files_tests.rs"]
629mod tests;