Skip to main content

soma_application/provider_registry/
resources.rs

1//! MCP resource support: dynamic resource template types, `RegistrySnapshot`
2//! resource matching, `ProviderRegistry::read_resource`, and the
3//! `build_snapshot()` bookkeeping that registers each provider's static and
4//! dynamic resources. Split out of `provider_registry.rs` to stay under the
5//! module size hard limit — see `docs/contracts/drop-in-provider-layout.md`
6//! for the contract this implements.
7
8use std::collections::{BTreeMap, HashMap};
9
10use soma_domain::{actions::scopes_satisfy, provider_validation::ProviderValidationError};
11use soma_provider_core::ProviderResource;
12
13use crate::{
14    provider_errors::ProviderError,
15    providers::resource_uri::{PathSegment, ResourcePath},
16};
17
18use super::{Provider, ProviderAuthMode, ProviderPrincipal, ProviderRegistry, RegistrySnapshot};
19
20/// A dynamic resource template a provider can serve, beyond the exact
21/// `uri_template` strings already listed in `catalog().resources` (which
22/// cover only static, parameter-free resources). Populated by file-based
23/// dynamic resource readers under `providers/resources/` — see
24/// `providers::resource_files`.
25#[derive(Debug, Clone)]
26pub struct DynamicResourceTemplate {
27    pub(crate) path: ResourcePath,
28    /// Human-readable template name advertised via MCP.
29    pub name: String,
30    /// Human-readable description of what the template resolves.
31    pub description: String,
32    /// MIME type of the resolved content, if known.
33    pub mime_type: Option<String>,
34    /// Scope required to invoke this template, enforced the same way
35    /// `ProviderResource::scope` is enforced for static resources — see
36    /// `ProviderRegistry::read_resource`. `None` means no scope beyond
37    /// baseline `Mounted` authentication is required.
38    pub scope: Option<String>,
39}
40
41impl DynamicResourceTemplate {
42    /// The RFC 6570-flavored URI template string (e.g.
43    /// `soma://resources/service/{name}`) advertised via MCP
44    /// `resources/templates/list`. `ResourcePath` itself stays private to
45    /// this crate — this is the only piece other crates need.
46    pub fn uri_template(&self) -> String {
47        self.path.uri_string()
48    }
49
50    /// Builds a template from raw path segments using the same bracket
51    /// syntax as `providers/resources/*.ts` filenames (`[name]` for a
52    /// parameter, `[...name]` for a catch-all — see
53    /// `docs/contracts/drop-in-provider-layout.md`). The public constructor
54    /// for any `Provider` (file-based or not) that wants to advertise a
55    /// dynamic resource template, since `ResourcePath` itself is private.
56    /// `scope` defaults to `None`; set the returned value's `scope` field
57    /// directly to require one.
58    pub fn from_path_segments(
59        segments: &[&str],
60        name: impl Into<String>,
61        description: impl Into<String>,
62        mime_type: Option<String>,
63    ) -> Result<Self, String> {
64        let path = crate::providers::resource_uri::parse_resource_path(segments)
65            .map_err(|error| error.0)?;
66        Ok(Self {
67            path,
68            name: name.into(),
69            description: description.into(),
70            mime_type,
71            scope: None,
72        })
73    }
74}
75
76/// Content resolved for a matched resource, ready to become an MCP
77/// `ResourceContents::text`/`::blob`.
78#[derive(Debug)]
79pub enum ResourceReadOutput {
80    /// Text content, becoming an MCP `ResourceContents::text`.
81    Text {
82        /// The resolved text content.
83        text: String,
84        /// MIME type of the content, if known.
85        mime_type: Option<String>,
86    },
87    /// Binary content, becoming an MCP `ResourceContents::blob`.
88    Blob {
89        /// Base64-encoded binary content.
90        blob_base64: String,
91        /// MIME type of the content, if known.
92        mime_type: Option<String>,
93    },
94}
95
96/// Per-snapshot resource indexes, built once in `build_snapshot()` and
97/// consulted by `RegistrySnapshot::match_resource`.
98pub(super) struct ResourceIndex {
99    pub(super) exact: HashMap<String, (String, ProviderResource)>,
100    pub(super) dynamic: Vec<(String, DynamicResourceTemplate)>,
101}
102
103impl ResourceIndex {
104    pub(super) fn new() -> Self {
105        Self {
106            exact: HashMap::new(),
107            dynamic: Vec::new(),
108        }
109    }
110
111    /// Registers `catalog`'s static resources (duplicate-name checking is
112    /// the caller's job via `insert_primitive`; this only tracks URI
113    /// collisions, including against dynamic templates — a static exact
114    /// resource and a zero-param dynamic template rendering to the same URI
115    /// are just as ambiguous as two dynamic templates of the same shape,
116    /// since the exact-match tier would silently and permanently shadow the
117    /// dynamic one) and `provider`'s dynamic resource templates (ambiguity
118    /// checked pairwise against everything already registered, in both
119    /// tiers).
120    ///
121    /// A provider whose kind can't serve resource reads
122    /// (`!provider.supports_resource_reads()`) has its declared resources
123    /// skipped here entirely — not indexed for `resources/list` or
124    /// `resources/read`, and not snapshot-validation errors either, since
125    /// `RegistrySnapshot::inspection_report` reads `catalog().resources`
126    /// directly (not through this index) and legitimately uses the field
127    /// for documentation/reporting even when nothing can serve it live.
128    /// This only prevents the previously-broken outcome: a resource that
129    /// lists successfully but always fails to read.
130    pub(super) fn register(
131        &mut self,
132        provider: &dyn Provider,
133        provider_name: &str,
134        resources: &[ProviderResource],
135    ) -> Result<(), ProviderValidationError> {
136        if !provider.supports_resource_reads() {
137            return Ok(());
138        }
139
140        for template in provider.dynamic_resource_templates() {
141            for (owner, other) in &self.dynamic {
142                if template.path.is_ambiguous_with(&other.path) {
143                    return Err(ProviderValidationError::new(
144                        "ambiguous_resource_template",
145                        format!(
146                            "resource template `{}` (provider `{provider_name}`) is ambiguous with `{}` (provider `{owner}`)",
147                            template.path.uri_string(),
148                            other.path.uri_string(),
149                        ),
150                    ));
151                }
152            }
153            for (uri, (owner, _)) in &self.exact {
154                if let Some(exact_path) = literal_resource_path(uri) {
155                    if template.path.is_ambiguous_with(&exact_path) {
156                        return Err(ProviderValidationError::new(
157                            "ambiguous_resource_template",
158                            format!(
159                                "resource template `{}` (provider `{provider_name}`) is ambiguous with exact resource `{uri}` (provider `{owner}`)",
160                                template.path.uri_string(),
161                            ),
162                        ));
163                    }
164                }
165            }
166            self.dynamic.push((provider_name.to_owned(), template));
167        }
168
169        for resource in resources {
170            if !resource.mcp.as_ref().map(|mcp| mcp.enabled).unwrap_or(true) {
171                // `mcp: { enabled: false }` opts a resource out of the MCP
172                // surface (matching how tools/prompts honor the same
173                // overlay) — never index it for live `resources/list` or
174                // `resources/read`. Reporting/remote-catalog code still
175                // preserves the raw field via `catalog().resources`.
176                continue;
177            }
178            if let Some(exact_path) = literal_resource_path(&resource.uri_template) {
179                for (owner, other) in &self.dynamic {
180                    if exact_path.is_ambiguous_with(&other.path) {
181                        return Err(ProviderValidationError::new(
182                            "ambiguous_resource_template",
183                            format!(
184                                "resource `{}` (provider `{provider_name}`) is ambiguous with dynamic template `{}` (provider `{owner}`)",
185                                resource.uri_template,
186                                other.path.uri_string(),
187                            ),
188                        ));
189                    }
190                }
191            }
192            if let Some((owner, _)) = self.exact.insert(
193                resource.uri_template.clone(),
194                (provider_name.to_owned(), resource.clone()),
195            ) {
196                return Err(ProviderValidationError::new(
197                    "duplicate_resource_uri",
198                    format!(
199                        "duplicate resource URI `{}` (already claimed by provider `{owner}`)",
200                        resource.uri_template
201                    ),
202                ));
203            }
204        }
205        Ok(())
206    }
207}
208
209/// Parses an exact resource's `uri_template` string into an all-literal
210/// `ResourcePath`, for reuse with `ResourcePath::is_ambiguous_with` when
211/// comparing against dynamic templates. Returns `None` for a URI that
212/// doesn't use the resource scheme at all (never ambiguous with a resource
213/// template either way).
214fn literal_resource_path(uri: &str) -> Option<ResourcePath> {
215    let segments = crate::providers::resource_uri::request_segments(uri)?;
216    Some(ResourcePath {
217        segments: segments
218            .into_iter()
219            .map(|segment| PathSegment::Literal(segment.to_owned()))
220            .collect(),
221    })
222}
223
224impl RegistrySnapshot {
225    /// Every provider's dynamic resource templates, for `resources/templates/list`.
226    pub fn dynamic_resource_templates(&self) -> &[(String, DynamicResourceTemplate)] {
227        &self.dynamic_resources
228    }
229
230    /// The live, MCP-visible, readable exact resources — i.e. exactly the
231    /// set `resources/read` can actually resolve. Already excludes
232    /// resources from providers that can't serve reads
233    /// (`!supports_resource_reads()`) and resources explicitly disabled via
234    /// `mcp: { enabled: false }`, since both are filtered out before
235    /// insertion in `ResourceIndex::register`. Use this for
236    /// `resources/list` instead of walking raw `catalogs` directly, or the
237    /// list can advertise resources that always fail to read.
238    pub fn exact_resources(&self) -> impl Iterator<Item = &ProviderResource> {
239        self.exact_resources.values().map(|(_, resource)| resource)
240    }
241
242    /// Resolves a request resource URI against exact resources first, then
243    /// dynamic templates in precedence order (exact-dynamic before
244    /// parameterized before catch-all — enforced by trying shorter/no-param
245    /// matches first via `is_dynamic()`), returning the owning provider
246    /// name, captured params, and the required scope (if any), regardless
247    /// of which tier matched.
248    pub fn match_resource(
249        &self,
250        uri: &str,
251    ) -> Option<(&str, BTreeMap<String, String>, Option<&str>)> {
252        if let Some((provider, resource)) = self.exact_resources.get(uri) {
253            return Some((
254                provider.as_str(),
255                BTreeMap::new(),
256                resource.scope.as_deref(),
257            ));
258        }
259        let segments: Vec<&str> = crate::providers::resource_uri::request_segments(uri)?;
260        // Exact-dynamic (zero-param) templates before parameterized before
261        // catch-all: `is_dynamic()` is false only for exact matches (no
262        // Param/CatchAll segments), so sorting by that flag first gives the
263        // right precedence without a separate tier enum.
264        let mut candidates: Vec<&(String, DynamicResourceTemplate)> =
265            self.dynamic_resources.iter().collect();
266        candidates.sort_by_key(|(_, template)| {
267            (
268                template.path.is_dynamic(),
269                matches!(
270                    template.path.segments.last(),
271                    Some(PathSegment::CatchAll(_))
272                ),
273            )
274        });
275        for (provider, template) in candidates {
276            if let Some(params) = template.path.match_segments(&segments) {
277                return Some((provider.as_str(), params, template.scope.as_deref()));
278            }
279        }
280        None
281    }
282}
283
284impl ProviderRegistry {
285    /// Matches `uri` against the active snapshot's exact resources and
286    /// dynamic resource templates (in that precedence order), enforces
287    /// `resource.scope` under `ProviderAuthMode::Mounted` the same way
288    /// `dispatch()` enforces `tool.scope`, then delegates to the owning
289    /// provider's `read_resource`.
290    ///
291    /// The match and the provider clone are fetched from the same read
292    /// lock acquisition, mirroring `dispatch()`'s pattern for tools — a
293    /// concurrent `refresh_file_providers()` between two separate lock
294    /// acquisitions could otherwise return params/scope from one snapshot
295    /// but a provider instance from a newer one (e.g. a hot-swapped
296    /// `resources/foo.md` -> `resources/foo.ts` letting a request matched
297    /// against the old unscoped static resource invoke the new
298    /// `soma:write`-scoped dynamic reader without ever being checked
299    /// against its scope).
300    pub async fn read_resource(
301        &self,
302        uri: &str,
303        principal: &ProviderPrincipal,
304        auth_mode: ProviderAuthMode,
305    ) -> Result<ResourceReadOutput, ProviderError> {
306        let (provider_name, params, resource_scope, provider) = {
307            let state = self
308                .state
309                .read()
310                .expect("provider registry lock should not be poisoned");
311            let Some((provider_name, params, scope)) = state.snapshot.match_resource(uri) else {
312                return Err(ProviderError::validation(
313                    "registry",
314                    uri,
315                    "unknown_resource",
316                    format!("unknown resource `{uri}`"),
317                ));
318            };
319            let provider_name = provider_name.to_owned();
320            let provider = state.providers.get(&provider_name).cloned();
321            (
322                provider_name,
323                params,
324                scope.map(ToOwned::to_owned),
325                provider,
326            )
327        };
328
329        if matches!(auth_mode, ProviderAuthMode::Mounted) {
330            if let Some(scope) = resource_scope.as_deref() {
331                if !scopes_satisfy(&principal.scopes, scope) {
332                    return Err(ProviderError::new(
333                        "insufficient_scope",
334                        provider_name.clone(),
335                        None,
336                        format!("resource `{uri}` requires scope `{scope}`"),
337                        "Authenticate with a token that includes the required scope.",
338                    ));
339                }
340            }
341        }
342
343        let Some(provider) = provider else {
344            return Err(ProviderError::new(
345                "provider_not_loaded",
346                provider_name,
347                None,
348                "provider is not loaded in the active registry",
349                "Reload providers and retry.",
350            ));
351        };
352        provider.read_resource(uri, &params).await
353    }
354}
355
356#[cfg(test)]
357#[path = "resources_tests.rs"]
358mod tests;