use std::collections::BTreeMap;
use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{
Completion, CompletionRequest, HookBus, PluginError, PluginResult, PluginStorage, Prompt,
PromptResult, Resource, ResourceResult, ResourceTemplate, ShutdownSignal, Tool, ToolResult,
VaultApi,
};
pub const MCP_TOOL_NAME_MAX_LEN: usize = 64;
const PLUGIN_RESOURCE_PATH_MAX_LEN: usize = 512;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct PluginDescriptor {
pub id: String,
pub name: String,
pub version: String,
pub description: String,
}
impl PluginDescriptor {
pub fn new(
id: impl Into<String>,
name: impl Into<String>,
version: impl Into<String>,
description: impl Into<String>,
) -> Self {
Self {
id: id.into(),
name: name.into(),
version: version.into(),
description: description.into(),
}
}
pub fn validate(&self) -> PluginResult<()> {
validate_plugin_id(&self.id)?;
if self.name.trim().is_empty() {
return Err(PluginError::invalid_input("plugin name must not be empty"));
}
if self.version.trim().is_empty() {
return Err(PluginError::invalid_input(
"plugin version must not be empty",
));
}
Ok(())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct PluginCapabilities {
pub config_reads: Vec<String>,
}
impl PluginCapabilities {
pub fn none() -> Self {
Self::default()
}
pub fn with_config_read(mut self, path: impl Into<String>) -> Self {
self.config_reads.push(path.into());
self
}
pub fn validate(&self) -> PluginResult<()> {
for declared in &self.config_reads {
let normalized = crate::vault::normalize_config_path(declared)?;
if &normalized != declared {
return Err(PluginError::invalid_input(format!(
"config_reads entry {declared:?} must be declared in normalized form ({normalized:?})"
)));
}
}
Ok(())
}
}
pub fn validate_plugin_id(id: &str) -> PluginResult<()> {
let mut chars = id.chars();
if id.len() > MCP_TOOL_NAME_MAX_LEN
|| !matches!(chars.next(), Some('a'..='z'))
|| !chars.all(|character| {
character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_'
})
{
return Err(PluginError::invalid_input(format!(
"invalid plugin id {id:?}; expected at most {MCP_TOOL_NAME_MAX_LEN} ASCII characters matching [a-z][a-z0-9_]*"
)));
}
Ok(())
}
pub fn validate_mcp_tool_name(name: &str) -> PluginResult<()> {
let length_is_valid = (1..=MCP_TOOL_NAME_MAX_LEN).contains(&name.len());
let characters_are_valid = name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'/'));
if !length_is_valid || !characters_are_valid {
return Err(PluginError::invalid_input(format!(
"invalid MCP tool name {name:?}; expected 1–{MCP_TOOL_NAME_MAX_LEN} ASCII characters from [A-Za-z0-9_./-]"
)));
}
Ok(())
}
pub fn namespaced_tool_name(plugin_id: &str, local_name: &str) -> PluginResult<String> {
validate_plugin_id(plugin_id)?;
validate_mcp_tool_name(local_name)?;
let public_name = format!("{plugin_id}_{local_name}");
validate_mcp_tool_name(&public_name)?;
Ok(public_name)
}
pub fn namespaced_prompt_name(plugin_id: &str, local_name: &str) -> PluginResult<String> {
namespaced_tool_name(plugin_id, local_name)
}
pub fn namespaced_resource_uri(plugin_id: &str, local_path: &str) -> PluginResult<String> {
validate_plugin_id(plugin_id)?;
validate_plugin_resource_path(local_path)?;
Ok(format!("{plugin_id}://{local_path}"))
}
pub fn namespaced_resource_template(plugin_id: &str, local_template: &str) -> PluginResult<String> {
validate_plugin_id(plugin_id)?;
validate_plugin_resource_path(local_template)?;
turbomcp_types::validate_uri_template(local_template).map_err(|error| {
PluginError::invalid_input(format!(
"invalid plugin resource template {local_template:?}: {error}"
))
})?;
Ok(format!("{plugin_id}://{local_template}"))
}
fn validate_plugin_resource_path(path: &str) -> PluginResult<()> {
let length_is_valid = (1..=PLUGIN_RESOURCE_PATH_MAX_LEN).contains(&path.len());
let characters_are_valid = path.bytes().all(|byte| byte.is_ascii_graphic());
if !length_is_valid || !characters_are_valid || path.contains("://") {
return Err(PluginError::invalid_input(format!(
"invalid plugin resource path {path:?}; expected 1–{PLUGIN_RESOURCE_PATH_MAX_LEN} printable ASCII characters without whitespace or an embedded scheme"
)));
}
Ok(())
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct PluginRequestContext {
pub request_id: String,
pub user_id: Option<String>,
pub session_id: Option<String>,
pub client_id: Option<String>,
pub metadata: BTreeMap<String, Value>,
}
impl PluginRequestContext {
pub fn new(request_id: impl Into<String>) -> Self {
Self {
request_id: request_id.into(),
..Self::default()
}
}
pub fn with_user_id(mut self, user_id: Option<String>) -> Self {
self.user_id = user_id;
self
}
pub fn with_session_id(mut self, session_id: Option<String>) -> Self {
self.session_id = session_id;
self
}
pub fn with_client_id(mut self, client_id: Option<String>) -> Self {
self.client_id = client_id;
self
}
pub fn with_metadata(mut self, metadata: BTreeMap<String, Value>) -> Self {
self.metadata = metadata;
self
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct PluginContext {
pub vault: VaultApi,
pub hooks: HookBus,
pub storage: PluginStorage,
pub shutdown: ShutdownSignal,
}
impl PluginContext {
pub fn new(
vault: VaultApi,
hooks: HookBus,
storage: PluginStorage,
shutdown: ShutdownSignal,
) -> Self {
Self {
vault,
hooks,
storage,
shutdown,
}
}
}
#[async_trait]
pub trait PluginProvider: Send + Sync {
fn tools(&self) -> Vec<Tool>;
async fn call_tool(
&self,
name: &str,
arguments: Value,
context: PluginRequestContext,
) -> PluginResult<ToolResult>;
fn resources(&self) -> Vec<Resource> {
Vec::new()
}
fn resource_templates(&self) -> Vec<ResourceTemplate> {
Vec::new()
}
async fn read_resource(
&self,
uri: &str,
context: PluginRequestContext,
) -> PluginResult<ResourceResult> {
let _ = context;
Err(PluginError::not_found(format!("unknown resource {uri:?}")))
}
fn prompts(&self) -> Vec<Prompt> {
Vec::new()
}
async fn get_prompt(
&self,
name: &str,
arguments: Option<Value>,
context: PluginRequestContext,
) -> PluginResult<PromptResult> {
let _ = (arguments, context);
Err(PluginError::not_found(format!("unknown prompt {name:?}")))
}
async fn complete(
&self,
request: CompletionRequest,
context: PluginRequestContext,
) -> PluginResult<Completion> {
let _ = (request, context);
Ok(Completion::none())
}
async fn start(&self) -> PluginResult<()> {
Ok(())
}
async fn shutdown(&self) {}
}
pub trait Plugin: Send + Sync {
fn descriptor(&self) -> PluginDescriptor;
fn capabilities(&self) -> PluginCapabilities {
PluginCapabilities::none()
}
fn build(&self, context: PluginContext) -> PluginResult<Arc<dyn PluginProvider>>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plugin_ids_are_stable_mcp_namespaces() {
for valid in ["tasks", "vault_events", "vector2"] {
validate_plugin_id(valid).expect("valid namespace");
}
for invalid in ["", "Tasks", "2vector", "vault-events", "vault events"] {
let error = validate_plugin_id(invalid).expect_err("invalid namespace");
assert_eq!(error.code, crate::PluginErrorCode::InvalidInput);
}
assert!(validate_plugin_id(&"p".repeat(MCP_TOOL_NAME_MAX_LEN + 1)).is_err());
}
#[test]
fn tool_names_enforce_mcp_sep_986() {
for valid in [
"a",
"getUser",
"user-profile/update",
"DATA_EXPORT_v2",
"admin.tools.list",
] {
validate_mcp_tool_name(valid).expect("SEP-986 name");
}
validate_mcp_tool_name(&"a".repeat(MCP_TOOL_NAME_MAX_LEN)).expect("64 characters");
for invalid in ["", "has space", "has,comma", "has:colon", "unicode_é"] {
let error = validate_mcp_tool_name(invalid).expect_err("invalid SEP-986 name");
assert_eq!(error.code, crate::PluginErrorCode::InvalidInput);
}
assert!(validate_mcp_tool_name(&"a".repeat(MCP_TOOL_NAME_MAX_LEN + 1)).is_err());
}
#[test]
fn namespaced_tool_names_validate_the_final_public_name() {
assert_eq!(
namespaced_tool_name("vector_search", "query/v2").expect("namespaced name"),
"vector_search_query/v2"
);
let oversized_plugin_id = "p".repeat(MCP_TOOL_NAME_MAX_LEN);
assert!(namespaced_tool_name(&oversized_plugin_id, "x").is_err());
}
#[test]
fn resource_uris_are_published_under_the_plugin_scheme() {
assert_eq!(
namespaced_resource_uri("tasks", "index/stats.json").expect("namespaced uri"),
"tasks://index/stats.json"
);
for invalid in ["", "has space", "other://stats", "unicode_é"] {
let error = namespaced_resource_uri("tasks", invalid).expect_err("invalid local path");
assert_eq!(error.code, crate::PluginErrorCode::InvalidInput);
}
assert!(namespaced_resource_uri("Tasks", "stats").is_err());
assert!(
namespaced_resource_uri("tasks", &"p".repeat(PLUGIN_RESOURCE_PATH_MAX_LEN + 1))
.is_err()
);
}
#[test]
fn resource_templates_are_checked_for_brace_structure() {
assert_eq!(
namespaced_resource_template("tasks", "note/{path}").expect("namespaced template"),
"tasks://note/{path}"
);
for invalid in ["note/{path", "note/{a{b}}", "note/}path{"] {
let error =
namespaced_resource_template("tasks", invalid).expect_err("malformed template");
assert_eq!(error.code, crate::PluginErrorCode::InvalidInput);
}
}
#[test]
fn prompt_names_share_the_tool_naming_rule() {
assert_eq!(
namespaced_prompt_name("tasks", "review_overdue").expect("namespaced prompt"),
"tasks_review_overdue"
);
assert!(namespaced_prompt_name("tasks", "has space").is_err());
}
#[test]
fn descriptors_require_user_facing_identity() {
let descriptor = PluginDescriptor {
id: "tasks".to_string(),
name: " ".to_string(),
version: "1.0.0".to_string(),
description: String::new(),
};
assert!(descriptor.validate().is_err());
let descriptor = PluginDescriptor {
name: "Tasks".to_string(),
version: String::new(),
..descriptor
};
assert!(descriptor.validate().is_err());
}
}