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