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}