Skip to main content

turbovault_plugin_api/
provider.rs

1use std::collections::BTreeMap;
2use std::sync::Arc;
3
4use async_trait::async_trait;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use crate::{
9    Completion, CompletionRequest, HookBus, PluginError, PluginResult, PluginStorage, Prompt,
10    PromptResult, Resource, ResourceResult, ResourceTemplate, ShutdownSignal, Tool, ToolResult,
11    VaultApi,
12};
13
14/// Maximum tool-name length recommended by MCP SEP-986.
15pub const MCP_TOOL_NAME_MAX_LEN: usize = 64;
16
17/// Maximum length of a plugin-local resource path.
18const PLUGIN_RESOURCE_PATH_MAX_LEN: usize = 512;
19
20/// Stable identity and compatibility metadata for a compiled-in plugin.
21///
22/// Construct with [`Self::new`]; the type is `#[non_exhaustive]` so identity
23/// metadata can grow without breaking existing plugins.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[non_exhaustive]
26pub struct PluginDescriptor {
27    /// Namespace used for every MCP capability, for example `tasks`.
28    pub id: String,
29    /// Human-readable plugin name.
30    pub name: String,
31    /// Plugin version.
32    pub version: String,
33    /// Short human-readable purpose.
34    pub description: String,
35}
36
37impl PluginDescriptor {
38    /// Construct a descriptor.
39    pub fn new(
40        id: impl Into<String>,
41        name: impl Into<String>,
42        version: impl Into<String>,
43        description: impl Into<String>,
44    ) -> Self {
45        Self {
46            id: id.into(),
47            name: name.into(),
48            version: version.into(),
49            description: description.into(),
50        }
51    }
52
53    /// Validate the descriptor before the host mounts any capabilities.
54    pub fn validate(&self) -> PluginResult<()> {
55        validate_plugin_id(&self.id)?;
56        if self.name.trim().is_empty() {
57            return Err(PluginError::invalid_input("plugin name must not be empty"));
58        }
59        if self.version.trim().is_empty() {
60            return Err(PluginError::invalid_input(
61                "plugin version must not be empty",
62            ));
63        }
64        Ok(())
65    }
66}
67
68/// What a plugin declares it needs beyond the note APIs.
69///
70/// Capabilities are declared by the plugin crate rather than listed centrally
71/// in this crate: the plugin owns the integration and therefore knows exactly
72/// what it reads, and a central list would duplicate that knowledge and drift
73/// from it. The default grants nothing — a plugin that declares nothing gets
74/// only the note surface every plugin gets.
75///
76/// The host validates a declaration at mount time, so an over-broad or
77/// malformed request stops the server from starting instead of surfacing as a
78/// confusing runtime denial.
79#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
80#[non_exhaustive]
81pub struct PluginCapabilities {
82    /// Exact vault-relative application-config files this plugin may read,
83    /// for example `.obsidian/plugins/obsidian-tasks-plugin/data.json`.
84    ///
85    /// Exact paths, not directory prefixes: a reviewer can see precisely what
86    /// a plugin reads, and granting a whole plugin folder would hand over
87    /// files nobody enumerated. A directory-scoped form can be added later if
88    /// a real plugin needs genuinely dynamic file names.
89    pub config_reads: Vec<String>,
90}
91
92impl PluginCapabilities {
93    /// A declaration granting nothing beyond the note APIs.
94    pub fn none() -> Self {
95        Self::default()
96    }
97
98    /// Declare one exact readable config path.
99    pub fn with_config_read(mut self, path: impl Into<String>) -> Self {
100        self.config_reads.push(path.into());
101        self
102    }
103
104    /// Validate every declared capability.
105    ///
106    /// Declarations are normalized on the way in, so a plugin cannot declare
107    /// `.obsidian/../.git/config` and have it accepted, and cannot declare a
108    /// path in one spelling then request it in another.
109    pub fn validate(&self) -> PluginResult<()> {
110        for declared in &self.config_reads {
111            let normalized = crate::vault::normalize_config_path(declared)?;
112            if &normalized != declared {
113                return Err(PluginError::invalid_input(format!(
114                    "config_reads entry {declared:?} must be declared in normalized form ({normalized:?})"
115                )));
116            }
117        }
118        Ok(())
119    }
120}
121
122/// Validate a plugin namespace.
123///
124/// IDs must start with a lowercase ASCII letter and then contain only
125/// lowercase ASCII letters, digits, or underscores.
126pub fn validate_plugin_id(id: &str) -> PluginResult<()> {
127    let mut chars = id.chars();
128    if id.len() > MCP_TOOL_NAME_MAX_LEN
129        || !matches!(chars.next(), Some('a'..='z'))
130        || !chars.all(|character| {
131            character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_'
132        })
133    {
134        return Err(PluginError::invalid_input(format!(
135            "invalid plugin id {id:?}; expected at most {MCP_TOOL_NAME_MAX_LEN} ASCII characters matching [a-z][a-z0-9_]*"
136        )));
137    }
138    Ok(())
139}
140
141/// Validate a complete tool name against MCP SEP-986.
142///
143/// TurboVault enforces the specification recommendation at registration time:
144/// names contain 1–64 ASCII letters, digits, underscores, dashes, dots, or
145/// forward slashes. See <https://modelcontextprotocol.io/seps/986-specify-format-for-tool-names>.
146pub fn validate_mcp_tool_name(name: &str) -> PluginResult<()> {
147    let length_is_valid = (1..=MCP_TOOL_NAME_MAX_LEN).contains(&name.len());
148    let characters_are_valid = name
149        .bytes()
150        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'/'));
151    if !length_is_valid || !characters_are_valid {
152        return Err(PluginError::invalid_input(format!(
153            "invalid MCP tool name {name:?}; expected 1–{MCP_TOOL_NAME_MAX_LEN} ASCII characters from [A-Za-z0-9_./-]"
154        )));
155    }
156    Ok(())
157}
158
159/// Build and validate the public MCP name for a plugin-local tool.
160pub fn namespaced_tool_name(plugin_id: &str, local_name: &str) -> PluginResult<String> {
161    validate_plugin_id(plugin_id)?;
162    validate_mcp_tool_name(local_name)?;
163    let public_name = format!("{plugin_id}_{local_name}");
164    validate_mcp_tool_name(&public_name)?;
165    Ok(public_name)
166}
167
168/// Build and validate the public MCP name for a plugin-local prompt.
169///
170/// MCP has no SEP-986 equivalent for prompt names, so TurboVault applies the
171/// tool-name rule to them as well: one identifier convention across a server's
172/// primitives is easier to reason about than two, and nothing the rule allows
173/// is unsafe in a prompt name.
174pub fn namespaced_prompt_name(plugin_id: &str, local_name: &str) -> PluginResult<String> {
175    namespaced_tool_name(plugin_id, local_name)
176}
177
178/// Build and validate the public URI for a plugin-local resource.
179///
180/// A plugin names a local path and the host publishes it under the plugin's own
181/// URI scheme, so `index/stats` in the `tasks` plugin becomes
182/// `tasks://index/stats`. This mirrors tool namespacing: a plugin never spells
183/// its own namespace, and cannot publish under one it does not own.
184pub fn namespaced_resource_uri(plugin_id: &str, local_path: &str) -> PluginResult<String> {
185    validate_plugin_id(plugin_id)?;
186    validate_plugin_resource_path(local_path)?;
187    Ok(format!("{plugin_id}://{local_path}"))
188}
189
190/// Build and validate the public URI template for a plugin-local resource
191/// template.
192///
193/// Namespaced exactly like a concrete resource URI, with the RFC 6570 brace
194/// structure checked as well so a typo in an expression name is caught when the
195/// plugin is mounted rather than when a client tries to expand it.
196pub fn namespaced_resource_template(plugin_id: &str, local_template: &str) -> PluginResult<String> {
197    validate_plugin_id(plugin_id)?;
198    validate_plugin_resource_path(local_template)?;
199    turbomcp_types::validate_uri_template(local_template).map_err(|error| {
200        PluginError::invalid_input(format!(
201            "invalid plugin resource template {local_template:?}: {error}"
202        ))
203    })?;
204    Ok(format!("{plugin_id}://{local_template}"))
205}
206
207/// Validate the local path half of a plugin resource URI.
208///
209/// A second `://` is rejected because the host routes a resource read by
210/// matching the scheme, and an embedded scheme would make the split ambiguous.
211fn validate_plugin_resource_path(path: &str) -> PluginResult<()> {
212    let length_is_valid = (1..=PLUGIN_RESOURCE_PATH_MAX_LEN).contains(&path.len());
213    let characters_are_valid = path.bytes().all(|byte| byte.is_ascii_graphic());
214    if !length_is_valid || !characters_are_valid || path.contains("://") {
215        return Err(PluginError::invalid_input(format!(
216            "invalid plugin resource path {path:?}; expected 1–{PLUGIN_RESOURCE_PATH_MAX_LEN} printable ASCII characters without whitespace or an embedded scheme"
217        )));
218    }
219    Ok(())
220}
221
222/// Curated request data passed to plugin tool calls.
223///
224/// Authentication internals and raw transport/session handles intentionally do
225/// not cross the plugin boundary.
226#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
227#[non_exhaustive]
228pub struct PluginRequestContext {
229    /// Host request identifier.
230    pub request_id: String,
231    /// Authenticated user identifier, when available.
232    pub user_id: Option<String>,
233    /// Stateful transport session identifier, when available.
234    pub session_id: Option<String>,
235    /// Client application identifier, when available.
236    pub client_id: Option<String>,
237    /// Serializable request metadata copied by the host.
238    pub metadata: BTreeMap<String, Value>,
239}
240
241impl PluginRequestContext {
242    /// Construct a request context for `request_id`.
243    pub fn new(request_id: impl Into<String>) -> Self {
244        Self {
245            request_id: request_id.into(),
246            ..Self::default()
247        }
248    }
249
250    /// Attach the authenticated user identifier.
251    pub fn with_user_id(mut self, user_id: Option<String>) -> Self {
252        self.user_id = user_id;
253        self
254    }
255
256    /// Attach the transport session identifier.
257    pub fn with_session_id(mut self, session_id: Option<String>) -> Self {
258        self.session_id = session_id;
259        self
260    }
261
262    /// Attach the client application identifier.
263    pub fn with_client_id(mut self, client_id: Option<String>) -> Self {
264        self.client_id = client_id;
265        self
266    }
267
268    /// Attach copied request metadata.
269    pub fn with_metadata(mut self, metadata: BTreeMap<String, Value>) -> Self {
270        self.metadata = metadata;
271        self
272    }
273}
274
275/// Capabilities provided to a plugin when it is constructed.
276#[derive(Debug, Clone)]
277#[non_exhaustive]
278pub struct PluginContext {
279    /// Curated vault operations, bound to this plugin's declaration.
280    pub vault: VaultApi,
281    /// Bounded, advisory hook/event bus.
282    pub hooks: HookBus,
283    /// Durable, plugin-private, per-vault key/value storage.
284    pub storage: PluginStorage,
285    /// Cooperative shutdown signal for background work.
286    pub shutdown: ShutdownSignal,
287}
288
289impl PluginContext {
290    /// Assemble a context. Called by the host.
291    pub fn new(
292        vault: VaultApi,
293        hooks: HookBus,
294        storage: PluginStorage,
295        shutdown: ShutdownSignal,
296    ) -> Self {
297        Self {
298            vault,
299            hooks,
300            storage,
301            shutdown,
302        }
303    }
304}
305
306/// Object-safe MCP tool provider implemented by a plugin.
307///
308/// New methods are added with defaults, so a plugin never has to change to
309/// keep compiling against a later version of this contract.
310#[async_trait]
311pub trait PluginProvider: Send + Sync {
312    /// Return local, unprefixed tool descriptors.
313    fn tools(&self) -> Vec<Tool>;
314
315    /// Call a local, unprefixed tool.
316    ///
317    /// The host applies a wall-clock budget to this call and isolates panics,
318    /// so a hung or panicking plugin degrades to one failed tool call rather
319    /// than a stalled or downed server. Long work belongs in a task the plugin
320    /// owns and tears down in [`Self::shutdown`].
321    async fn call_tool(
322        &self,
323        name: &str,
324        arguments: Value,
325        context: PluginRequestContext,
326    ) -> PluginResult<ToolResult>;
327
328    /// Return local, unprefixed resource descriptors.
329    ///
330    /// Resources are the primitive a client attaches as context on the user's
331    /// initiative, which is different from a tool the model decides to call.
332    /// State a plugin maintains — index statistics, a rendered summary — is
333    /// usually better offered here than as a tool nothing thinks to invoke.
334    ///
335    /// Give each `uri` a plugin-local path; the host publishes it under this
336    /// plugin's scheme. Default: none.
337    ///
338    /// List only what is worth enumerating. A URI space too large or too
339    /// volatile to enumerate belongs in [`Self::resource_templates`] instead.
340    fn resources(&self) -> Vec<Resource> {
341        Vec::new()
342    }
343
344    /// Return local, unprefixed URI templates for resources this plugin serves
345    /// but does not enumerate.
346    ///
347    /// This is how a plugin exposes a URI space that is large, open-ended, or
348    /// changes as the vault does — `note/{path}` rather than one entry per
349    /// note. TurboVault never sends `list_changed` notifications, so a resource
350    /// that comes and goes has to be reachable through a template; an
351    /// enumerated list a client cached at startup would go stale silently.
352    ///
353    /// The host routes this plugin's entire scheme, so a read of any URI under
354    /// it reaches [`Self::read_resource`] whether or not it was listed.
355    /// Default: none.
356    fn resource_templates(&self) -> Vec<ResourceTemplate> {
357        Vec::new()
358    }
359
360    /// Read one local, unprefixed resource path.
361    ///
362    /// Called for any URI in this plugin's scheme, including ones matching a
363    /// template rather than an enumerated entry, so validate what you receive.
364    ///
365    /// The host rewrites the URI on every returned content into this plugin's
366    /// namespace, so what a client reads back always matches what it asked for
367    /// and a plugin cannot publish content under a URI it does not own.
368    ///
369    /// Bounded and panic-isolated exactly like [`Self::call_tool`]. Default:
370    /// not found.
371    async fn read_resource(
372        &self,
373        uri: &str,
374        context: PluginRequestContext,
375    ) -> PluginResult<ResourceResult> {
376        let _ = context;
377        Err(PluginError::not_found(format!("unknown resource {uri:?}")))
378    }
379
380    /// Return local, unprefixed prompt descriptors.
381    ///
382    /// Prompts are user-invoked, so this is how a plugin offers a workflow a
383    /// person starts deliberately rather than one the model reaches for.
384    /// Names are namespaced like tools. Default: none.
385    fn prompts(&self) -> Vec<Prompt> {
386        Vec::new()
387    }
388
389    /// Render one local, unprefixed prompt.
390    ///
391    /// Bounded and panic-isolated exactly like [`Self::call_tool`]. Default:
392    /// not found.
393    async fn get_prompt(
394        &self,
395        name: &str,
396        arguments: Option<Value>,
397        context: PluginRequestContext,
398    ) -> PluginResult<PromptResult> {
399        let _ = (arguments, context);
400        Err(PluginError::not_found(format!("unknown prompt {name:?}")))
401    }
402
403    /// Suggest values for an argument a user is part-way through typing.
404    ///
405    /// This is what makes a resource template usable rather than merely
406    /// declared: a client can offer the paths that exist instead of asking a
407    /// person to guess one. It applies to prompt arguments equally.
408    ///
409    /// Called on every keystroke in some clients, so answer from state the
410    /// plugin already holds. The host applies the same wall-clock budget as a
411    /// tool call and truncates the result to the protocol's limit, so returning
412    /// everything known is safe.
413    ///
414    /// Default: no suggestions, which is a valid answer for any argument.
415    async fn complete(
416        &self,
417        request: CompletionRequest,
418        context: PluginRequestContext,
419    ) -> PluginResult<Completion> {
420        let _ = (request, context);
421        Ok(Completion::none())
422    }
423
424    /// Begin background work.
425    ///
426    /// Called once, on the host's runtime, after every plugin is mounted and
427    /// before the transport starts serving. This is where a worker belongs:
428    /// [`Plugin::build`] is synchronous and may run outside a runtime, so
429    /// spawning there is not sound.
430    ///
431    /// Return promptly. Spawn the loop and keep its handle; the
432    /// [`ShutdownSignal`] from [`PluginContext`] tells it when to stop, and
433    /// [`Self::shutdown`] is where to await the handle.
434    ///
435    /// A failure here stops the server from starting: a plugin that cannot
436    /// begin its work would otherwise serve tools backed by state it is not
437    /// maintaining. Default: nothing.
438    async fn start(&self) -> PluginResult<()> {
439        Ok(())
440    }
441
442    /// Release resources during graceful host shutdown.
443    ///
444    /// Called once, after the host stops accepting requests and after the
445    /// shutdown signal has fired, so a worker has already been told to stop.
446    /// Await your task handles here. Default: nothing.
447    async fn shutdown(&self) {}
448}
449
450/// Factory contract for a compiled-in plugin.
451pub trait Plugin: Send + Sync {
452    /// Return plugin identity before construction.
453    fn descriptor(&self) -> PluginDescriptor;
454
455    /// Declare what this plugin needs beyond the note APIs.
456    ///
457    /// Default: nothing. The host validates the declaration and binds it to
458    /// this plugin's [`crate::VaultApi`] before construction, so a capability
459    /// is scoped to the plugin that asked for it.
460    fn capabilities(&self) -> PluginCapabilities {
461        PluginCapabilities::none()
462    }
463
464    /// Build the provider using host-curated capabilities.
465    fn build(&self, context: PluginContext) -> PluginResult<Arc<dyn PluginProvider>>;
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    #[test]
473    fn plugin_ids_are_stable_mcp_namespaces() {
474        for valid in ["tasks", "vault_events", "vector2"] {
475            validate_plugin_id(valid).expect("valid namespace");
476        }
477        for invalid in ["", "Tasks", "2vector", "vault-events", "vault events"] {
478            let error = validate_plugin_id(invalid).expect_err("invalid namespace");
479            assert_eq!(error.code, crate::PluginErrorCode::InvalidInput);
480        }
481        assert!(validate_plugin_id(&"p".repeat(MCP_TOOL_NAME_MAX_LEN + 1)).is_err());
482    }
483
484    #[test]
485    fn tool_names_enforce_mcp_sep_986() {
486        for valid in [
487            "a",
488            "getUser",
489            "user-profile/update",
490            "DATA_EXPORT_v2",
491            "admin.tools.list",
492        ] {
493            validate_mcp_tool_name(valid).expect("SEP-986 name");
494        }
495        validate_mcp_tool_name(&"a".repeat(MCP_TOOL_NAME_MAX_LEN)).expect("64 characters");
496
497        for invalid in ["", "has space", "has,comma", "has:colon", "unicode_é"] {
498            let error = validate_mcp_tool_name(invalid).expect_err("invalid SEP-986 name");
499            assert_eq!(error.code, crate::PluginErrorCode::InvalidInput);
500        }
501        assert!(validate_mcp_tool_name(&"a".repeat(MCP_TOOL_NAME_MAX_LEN + 1)).is_err());
502    }
503
504    #[test]
505    fn namespaced_tool_names_validate_the_final_public_name() {
506        assert_eq!(
507            namespaced_tool_name("vector_search", "query/v2").expect("namespaced name"),
508            "vector_search_query/v2"
509        );
510        let oversized_plugin_id = "p".repeat(MCP_TOOL_NAME_MAX_LEN);
511        assert!(namespaced_tool_name(&oversized_plugin_id, "x").is_err());
512    }
513
514    #[test]
515    fn resource_uris_are_published_under_the_plugin_scheme() {
516        assert_eq!(
517            namespaced_resource_uri("tasks", "index/stats.json").expect("namespaced uri"),
518            "tasks://index/stats.json"
519        );
520        // A second scheme would make the host's prefix split ambiguous.
521        for invalid in ["", "has space", "other://stats", "unicode_é"] {
522            let error = namespaced_resource_uri("tasks", invalid).expect_err("invalid local path");
523            assert_eq!(error.code, crate::PluginErrorCode::InvalidInput);
524        }
525        assert!(namespaced_resource_uri("Tasks", "stats").is_err());
526        assert!(
527            namespaced_resource_uri("tasks", &"p".repeat(PLUGIN_RESOURCE_PATH_MAX_LEN + 1))
528                .is_err()
529        );
530    }
531
532    #[test]
533    fn resource_templates_are_checked_for_brace_structure() {
534        assert_eq!(
535            namespaced_resource_template("tasks", "note/{path}").expect("namespaced template"),
536            "tasks://note/{path}"
537        );
538        // Caught at mount time rather than when a client tries to expand it.
539        for invalid in ["note/{path", "note/{a{b}}", "note/}path{"] {
540            let error =
541                namespaced_resource_template("tasks", invalid).expect_err("malformed template");
542            assert_eq!(error.code, crate::PluginErrorCode::InvalidInput);
543        }
544    }
545
546    #[test]
547    fn prompt_names_share_the_tool_naming_rule() {
548        assert_eq!(
549            namespaced_prompt_name("tasks", "review_overdue").expect("namespaced prompt"),
550            "tasks_review_overdue"
551        );
552        assert!(namespaced_prompt_name("tasks", "has space").is_err());
553    }
554
555    #[test]
556    fn descriptors_require_user_facing_identity() {
557        let descriptor = PluginDescriptor {
558            id: "tasks".to_string(),
559            name: " ".to_string(),
560            version: "1.0.0".to_string(),
561            description: String::new(),
562        };
563        assert!(descriptor.validate().is_err());
564
565        let descriptor = PluginDescriptor {
566            name: "Tasks".to_string(),
567            version: String::new(),
568            ..descriptor
569        };
570        assert!(descriptor.validate().is_err());
571    }
572}