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, ¶ms).await
353 }
354}
355
356#[cfg(test)]
357#[path = "resources_tests.rs"]
358mod tests;