873 lines
29 KiB
Rust
873 lines
29 KiB
Rust
//! Project-scoped plugin activation and per-agent capability assignments.
|
|
|
|
use std::collections::HashSet;
|
|
use std::sync::Arc;
|
|
|
|
use domain::{
|
|
AgentContextStore, AgentId, AgentPluginAssignments, MarkdownDoc, PluginId, PluginManifest,
|
|
PluginManifestValidator, PluginMcpServerId, PluginMcpServerSpec, PluginPackageStore,
|
|
PluginRegistryStore, PluginSkillId, PluginSkillRef, PluginStoreError, PluginToolRef, Project,
|
|
ProjectPluginActivation, ProjectPluginSettings, ProjectPluginSettingsStore,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::AppError;
|
|
|
|
fn package_error(error: PluginStoreError) -> AppError {
|
|
match error {
|
|
PluginStoreError::NotFound => AppError::NotFound("plugin package".to_owned()),
|
|
PluginStoreError::Invalid(message) => AppError::Invalid(message),
|
|
other => AppError::Store(other.to_string()),
|
|
}
|
|
}
|
|
|
|
/// Shared resolver for installed, runtime-active plugin manifests.
|
|
pub struct ProjectPluginResolver {
|
|
settings: Arc<dyn ProjectPluginSettingsStore>,
|
|
packages: Arc<dyn PluginPackageStore>,
|
|
registry: Arc<dyn PluginRegistryStore>,
|
|
validator: Arc<dyn PluginManifestValidator>,
|
|
}
|
|
|
|
impl ProjectPluginResolver {
|
|
/// Builds the resolver from replaceable ports.
|
|
#[must_use]
|
|
pub fn new(
|
|
settings: Arc<dyn ProjectPluginSettingsStore>,
|
|
packages: Arc<dyn PluginPackageStore>,
|
|
registry: Arc<dyn PluginRegistryStore>,
|
|
validator: Arc<dyn PluginManifestValidator>,
|
|
) -> Self {
|
|
Self {
|
|
settings,
|
|
packages,
|
|
registry,
|
|
validator,
|
|
}
|
|
}
|
|
|
|
/// Loads the persisted project document.
|
|
pub async fn settings(
|
|
&self,
|
|
project_root: &domain::ProjectPath,
|
|
) -> Result<ProjectPluginSettings, AppError> {
|
|
self.settings
|
|
.load(project_root)
|
|
.await
|
|
.map_err(AppError::from)
|
|
}
|
|
|
|
/// Resolves one plugin only when enabled for the project and runtime-active
|
|
/// globally. Missing/uninstalled plugins yield `None`, preserving tombstones.
|
|
pub async fn enabled_manifest(
|
|
&self,
|
|
project_root: &domain::ProjectPath,
|
|
plugin_id: &PluginId,
|
|
) -> Result<Option<PluginManifest>, AppError> {
|
|
let settings = self.settings(project_root).await?;
|
|
if !settings.is_enabled(plugin_id) {
|
|
return Ok(None);
|
|
}
|
|
let registry = self
|
|
.registry
|
|
.load_registry()
|
|
.await
|
|
.map_err(|e| AppError::Store(e.to_string()))?;
|
|
let Some(entry) = registry.find(plugin_id) else {
|
|
return Ok(None);
|
|
};
|
|
if !entry.lifecycle_state.is_runtime_active() {
|
|
return Ok(None);
|
|
}
|
|
let package = self
|
|
.packages
|
|
.list_installed()
|
|
.await
|
|
.map_err(package_error)?
|
|
.into_iter()
|
|
.find(|p| p.plugin_id.as_ref() == Some(plugin_id));
|
|
let Some(package) = package else {
|
|
return Ok(None);
|
|
};
|
|
let bytes = self
|
|
.packages
|
|
.read_manifest(&package)
|
|
.await
|
|
.map_err(package_error)?;
|
|
self.validator
|
|
.validate(&bytes.bytes, &package)
|
|
.map(Some)
|
|
.map_err(|e| AppError::Invalid(e.to_string()))
|
|
}
|
|
|
|
/// Reads a contributed Markdown body through the confined package port.
|
|
pub async fn read_skill_body(
|
|
&self,
|
|
plugin_id: &PluginId,
|
|
path: &domain::RelativePath,
|
|
) -> Result<MarkdownDoc, AppError> {
|
|
let bytes = self
|
|
.packages
|
|
.read_installed_file(plugin_id, path)
|
|
.await
|
|
.map_err(package_error)?;
|
|
let body = String::from_utf8(bytes)
|
|
.map_err(|_| AppError::Invalid("plugin skill must be UTF-8 Markdown".to_owned()))?;
|
|
if body.trim().is_empty() {
|
|
return Err(AppError::Invalid(
|
|
"plugin skill body must not be empty".to_owned(),
|
|
));
|
|
}
|
|
Ok(MarkdownDoc::new(body))
|
|
}
|
|
|
|
/// Resolves the runtime-active MCP servers that carry at least one tool
|
|
/// explicitly assigned to `agent_id`.
|
|
///
|
|
/// The persisted [`PluginToolRef`] remains the authorization/source of intent;
|
|
/// this projection only turns its owning server into launch-ready wiring for a
|
|
/// native MCP client. Disabled, uninstalled, invalid, non-auto-start and
|
|
/// unassigned servers are deliberately omitted.
|
|
pub async fn assigned_mcp_servers(
|
|
&self,
|
|
project_root: &domain::ProjectPath,
|
|
agent_id: domain::AgentId,
|
|
) -> Result<Vec<PluginMcpServerSpec>, AppError> {
|
|
let settings = self.settings(project_root).await?;
|
|
let Some(assignments) = settings.agents.iter().find(|a| a.agent_id == agent_id) else {
|
|
return Ok(Vec::new());
|
|
};
|
|
let packages = self
|
|
.packages
|
|
.list_installed()
|
|
.await
|
|
.map_err(package_error)?;
|
|
let app_data_dir = self.packages.app_data_dir_label();
|
|
let mut seen = HashSet::new();
|
|
let mut specs = Vec::new();
|
|
|
|
for reference in &assignments.tools {
|
|
let key = (reference.plugin_id.clone(), reference.server_id.clone());
|
|
if !seen.insert(key) {
|
|
continue;
|
|
}
|
|
let Some(manifest) = self
|
|
.enabled_manifest(project_root, &reference.plugin_id)
|
|
.await?
|
|
else {
|
|
continue;
|
|
};
|
|
let Some(server) = manifest
|
|
.contributes
|
|
.mcp_servers
|
|
.into_iter()
|
|
.find(|server| server.id == reference.server_id && server.auto_start)
|
|
else {
|
|
continue;
|
|
};
|
|
let Some(package_root) = packages
|
|
.iter()
|
|
.find(|package| package.plugin_id.as_ref() == Some(&reference.plugin_id))
|
|
.map(|package| package.root.as_str())
|
|
else {
|
|
continue;
|
|
};
|
|
let command =
|
|
substitute_plugin_vars(&server.command, package_root, app_data_dir.as_deref());
|
|
let command = if server.allow_absolute_command || looks_absolute(&command) {
|
|
command
|
|
} else {
|
|
format!("{}/{}", package_root.trim_end_matches(['/', '\\']), command)
|
|
};
|
|
specs.push(PluginMcpServerSpec {
|
|
identity: format!(
|
|
"plugin:{}:{}",
|
|
reference.plugin_id.as_str(),
|
|
server.id.as_str()
|
|
),
|
|
plugin_id: reference.plugin_id.clone(),
|
|
server_id: server.id,
|
|
display_name: server.display_name,
|
|
command,
|
|
args: server
|
|
.args
|
|
.into_iter()
|
|
.map(|arg| substitute_plugin_vars(&arg, package_root, app_data_dir.as_deref()))
|
|
.collect(),
|
|
env: server
|
|
.env
|
|
.into_iter()
|
|
.map(|(key, value)| {
|
|
(
|
|
key,
|
|
substitute_plugin_vars(&value, package_root, app_data_dir.as_deref()),
|
|
)
|
|
})
|
|
.collect(),
|
|
cwd: substitute_plugin_vars(
|
|
server.cwd.as_deref().unwrap_or("${pluginRoot}"),
|
|
package_root,
|
|
app_data_dir.as_deref(),
|
|
),
|
|
transport: server.transport,
|
|
});
|
|
}
|
|
Ok(specs)
|
|
}
|
|
}
|
|
|
|
fn substitute_plugin_vars(raw: &str, plugin_root: &str, app_data_dir: Option<&str>) -> String {
|
|
let value = raw.replace("${pluginRoot}", plugin_root);
|
|
match app_data_dir {
|
|
Some(app_data_dir) => value.replace("${appDataDir}", app_data_dir),
|
|
None => value,
|
|
}
|
|
}
|
|
|
|
fn looks_absolute(path: &str) -> bool {
|
|
path.starts_with('/') || path.starts_with('\\') || path.as_bytes().get(1) == Some(&b':')
|
|
}
|
|
|
|
/// Read model consumed by project settings surfaces.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ProjectPluginSettingsView {
|
|
/// Persisted activation and assignment document.
|
|
pub settings: ProjectPluginSettings,
|
|
/// Installed plugin manifests. Runtime failures are omitted from this
|
|
/// catalogue but their settings remain in `settings` as tombstones.
|
|
pub installed: Vec<PluginManifest>,
|
|
}
|
|
|
|
/// Lists project plugin settings and the globally installed catalogue.
|
|
pub struct ListProjectPluginSettings {
|
|
resolver: Arc<ProjectPluginResolver>,
|
|
packages: Arc<dyn PluginPackageStore>,
|
|
validator: Arc<dyn PluginManifestValidator>,
|
|
}
|
|
|
|
impl ListProjectPluginSettings {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(
|
|
resolver: Arc<ProjectPluginResolver>,
|
|
packages: Arc<dyn PluginPackageStore>,
|
|
validator: Arc<dyn PluginManifestValidator>,
|
|
) -> Self {
|
|
Self {
|
|
resolver,
|
|
packages,
|
|
validator,
|
|
}
|
|
}
|
|
|
|
/// Executes the read.
|
|
pub async fn execute(&self, project: Project) -> Result<ProjectPluginSettingsView, AppError> {
|
|
let settings = self.resolver.settings(&project.root).await?;
|
|
let mut installed = Vec::new();
|
|
for package in self
|
|
.packages
|
|
.list_installed()
|
|
.await
|
|
.map_err(package_error)?
|
|
{
|
|
let bytes = self
|
|
.packages
|
|
.read_manifest(&package)
|
|
.await
|
|
.map_err(package_error)?;
|
|
if let Ok(manifest) = self.validator.validate(&bytes.bytes, &package) {
|
|
installed.push(manifest);
|
|
}
|
|
}
|
|
installed.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str()));
|
|
Ok(ProjectPluginSettingsView {
|
|
settings,
|
|
installed,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Toggles one globally installed plugin for a project.
|
|
pub struct SetProjectPluginEnabled {
|
|
store: Arc<dyn ProjectPluginSettingsStore>,
|
|
packages: Arc<dyn PluginPackageStore>,
|
|
}
|
|
|
|
impl SetProjectPluginEnabled {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(
|
|
store: Arc<dyn ProjectPluginSettingsStore>,
|
|
packages: Arc<dyn PluginPackageStore>,
|
|
) -> Self {
|
|
Self { store, packages }
|
|
}
|
|
|
|
/// Persists activation. Enabling requires a currently installed plugin;
|
|
/// disabling a missing plugin remains valid and keeps assignments intact.
|
|
pub async fn execute(
|
|
&self,
|
|
project: Project,
|
|
plugin_id: PluginId,
|
|
enabled: bool,
|
|
) -> Result<ProjectPluginSettings, AppError> {
|
|
if enabled {
|
|
let exists = self
|
|
.packages
|
|
.list_installed()
|
|
.await
|
|
.map_err(package_error)?
|
|
.iter()
|
|
.any(|p| p.plugin_id.as_ref() == Some(&plugin_id));
|
|
if !exists {
|
|
return Err(AppError::NotFound(format!("plugin {}", plugin_id.as_str())));
|
|
}
|
|
}
|
|
let mut settings = self.store.load(&project.root).await?;
|
|
match settings
|
|
.plugins
|
|
.iter_mut()
|
|
.find(|p| p.plugin_id == plugin_id)
|
|
{
|
|
Some(entry) => entry.enabled = enabled,
|
|
None => settings
|
|
.plugins
|
|
.push(ProjectPluginActivation { plugin_id, enabled }),
|
|
}
|
|
self.store.save(&project.root, &settings).await?;
|
|
Ok(settings)
|
|
}
|
|
}
|
|
|
|
/// Mutation kind shared by skill/tool assignment use cases.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum AssignmentMutation {
|
|
/// Add the capability when it is not already assigned.
|
|
Assign,
|
|
/// Remove the capability when it is currently assigned.
|
|
Unassign,
|
|
}
|
|
|
|
/// Assigns or unassigns one plugin skill while retaining dangling tombstones.
|
|
pub struct MutateAgentPluginSkill {
|
|
store: Arc<dyn ProjectPluginSettingsStore>,
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
resolver: Arc<ProjectPluginResolver>,
|
|
}
|
|
|
|
impl MutateAgentPluginSkill {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(
|
|
store: Arc<dyn ProjectPluginSettingsStore>,
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
resolver: Arc<ProjectPluginResolver>,
|
|
) -> Self {
|
|
Self {
|
|
store,
|
|
contexts,
|
|
resolver,
|
|
}
|
|
}
|
|
|
|
/// Applies an idempotent mutation.
|
|
pub async fn execute(
|
|
&self,
|
|
project: Project,
|
|
agent_id: AgentId,
|
|
reference: PluginSkillRef,
|
|
mutation: AssignmentMutation,
|
|
) -> Result<ProjectPluginSettings, AppError> {
|
|
ensure_agent(&*self.contexts, &project, agent_id).await?;
|
|
if mutation == AssignmentMutation::Assign {
|
|
let manifest = self
|
|
.resolver
|
|
.enabled_manifest(&project.root, &reference.plugin_id)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
AppError::Invalid(
|
|
"plugin is not enabled and runtime-active for this project".to_owned(),
|
|
)
|
|
})?;
|
|
if !manifest
|
|
.contributes
|
|
.skills
|
|
.iter()
|
|
.any(|s| s.id == reference.skill_id)
|
|
{
|
|
return Err(AppError::NotFound(format!(
|
|
"plugin skill {}",
|
|
reference.skill_id.as_str()
|
|
)));
|
|
}
|
|
}
|
|
let mut settings = self.store.load(&project.root).await?;
|
|
let assignments = assignments_mut(&mut settings, agent_id);
|
|
match mutation {
|
|
AssignmentMutation::Assign if !assignments.skills.contains(&reference) => {
|
|
assignments.skills.push(reference)
|
|
}
|
|
AssignmentMutation::Unassign => assignments.skills.retain(|r| r != &reference),
|
|
_ => {}
|
|
}
|
|
self.store.save(&project.root, &settings).await?;
|
|
Ok(settings)
|
|
}
|
|
}
|
|
|
|
/// Assigns or unassigns one namespaced external MCP tool.
|
|
pub struct MutateAgentPluginTool {
|
|
store: Arc<dyn ProjectPluginSettingsStore>,
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
resolver: Arc<ProjectPluginResolver>,
|
|
}
|
|
|
|
impl MutateAgentPluginTool {
|
|
/// Builds the use case.
|
|
#[must_use]
|
|
pub fn new(
|
|
store: Arc<dyn ProjectPluginSettingsStore>,
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
resolver: Arc<ProjectPluginResolver>,
|
|
) -> Self {
|
|
Self {
|
|
store,
|
|
contexts,
|
|
resolver,
|
|
}
|
|
}
|
|
|
|
/// Applies an idempotent mutation. Assignment validates the enabled server;
|
|
/// the dynamic tool name is validated when its MCP catalogue is available.
|
|
pub async fn execute(
|
|
&self,
|
|
project: Project,
|
|
agent_id: AgentId,
|
|
reference: PluginToolRef,
|
|
mutation: AssignmentMutation,
|
|
) -> Result<ProjectPluginSettings, AppError> {
|
|
ensure_agent(&*self.contexts, &project, agent_id).await?;
|
|
if reference.tool_name.trim().is_empty() {
|
|
return Err(AppError::Invalid("plugin tool name is required".to_owned()));
|
|
}
|
|
if mutation == AssignmentMutation::Assign {
|
|
let manifest = self
|
|
.resolver
|
|
.enabled_manifest(&project.root, &reference.plugin_id)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
AppError::Invalid(
|
|
"plugin is not enabled and runtime-active for this project".to_owned(),
|
|
)
|
|
})?;
|
|
if !manifest
|
|
.contributes
|
|
.mcp_servers
|
|
.iter()
|
|
.any(|s| s.id == reference.server_id)
|
|
{
|
|
return Err(AppError::NotFound(format!(
|
|
"plugin MCP server {}",
|
|
reference.server_id.as_str()
|
|
)));
|
|
}
|
|
}
|
|
let mut settings = self.store.load(&project.root).await?;
|
|
let assignments = assignments_mut(&mut settings, agent_id);
|
|
match mutation {
|
|
AssignmentMutation::Assign if !assignments.tools.contains(&reference) => {
|
|
assignments.tools.push(reference)
|
|
}
|
|
AssignmentMutation::Unassign => assignments.tools.retain(|r| r != &reference),
|
|
_ => {}
|
|
}
|
|
self.store.save(&project.root, &settings).await?;
|
|
Ok(settings)
|
|
}
|
|
}
|
|
|
|
async fn ensure_agent(
|
|
store: &dyn AgentContextStore,
|
|
project: &Project,
|
|
agent_id: AgentId,
|
|
) -> Result<(), AppError> {
|
|
if store
|
|
.load_manifest(project)
|
|
.await?
|
|
.entries
|
|
.iter()
|
|
.any(|e| e.agent_id == agent_id)
|
|
{
|
|
Ok(())
|
|
} else {
|
|
Err(AppError::NotFound(format!("agent {agent_id}")))
|
|
}
|
|
}
|
|
|
|
fn assignments_mut(
|
|
settings: &mut ProjectPluginSettings,
|
|
agent_id: AgentId,
|
|
) -> &mut AgentPluginAssignments {
|
|
if let Some(index) = settings.agents.iter().position(|a| a.agent_id == agent_id) {
|
|
return &mut settings.agents[index];
|
|
}
|
|
settings.agents.push(AgentPluginAssignments {
|
|
agent_id,
|
|
skills: Vec::new(),
|
|
tools: Vec::new(),
|
|
});
|
|
settings
|
|
.agents
|
|
.last_mut()
|
|
.expect("assignment was just inserted")
|
|
}
|
|
|
|
/// Convenience constructors for driving adapters accepting string ids.
|
|
pub fn plugin_skill_ref(plugin_id: String, skill_id: String) -> Result<PluginSkillRef, AppError> {
|
|
Ok(PluginSkillRef {
|
|
plugin_id: PluginId::new(plugin_id).map_err(|e| AppError::Invalid(e.to_string()))?,
|
|
skill_id: PluginSkillId::new(skill_id).map_err(|e| AppError::Invalid(e.to_string()))?,
|
|
})
|
|
}
|
|
|
|
/// Convenience constructor for a namespaced plugin tool reference.
|
|
pub fn plugin_tool_ref(
|
|
plugin_id: String,
|
|
server_id: String,
|
|
tool_name: String,
|
|
) -> Result<PluginToolRef, AppError> {
|
|
Ok(PluginToolRef {
|
|
plugin_id: PluginId::new(plugin_id).map_err(|e| AppError::Invalid(e.to_string()))?,
|
|
server_id: PluginMcpServerId::new(server_id)
|
|
.map_err(|e| AppError::Invalid(e.to_string()))?,
|
|
tool_name,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use async_trait::async_trait;
|
|
use domain::{
|
|
ContentHash, LocalPath, PluginActivationScope, PluginCapability, PluginContributionSet,
|
|
PluginInstallSource, PluginLifecycleState, PluginManifestBytes, PluginManifestError,
|
|
PluginMcpServerContribution, PluginPackageRef, PluginRegistry, PluginRegistryEntry,
|
|
PluginTrustLevel, PluginVersion, ProjectPath, RelativePath, RemovalOutcome,
|
|
StagedPluginPackage,
|
|
};
|
|
use std::sync::Mutex;
|
|
|
|
struct MemorySettings {
|
|
settings: Mutex<ProjectPluginSettings>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ProjectPluginSettingsStore for MemorySettings {
|
|
async fn load(
|
|
&self,
|
|
_project_root: &ProjectPath,
|
|
) -> Result<ProjectPluginSettings, domain::StoreError> {
|
|
Ok(self.settings.lock().unwrap().clone())
|
|
}
|
|
|
|
async fn save(
|
|
&self,
|
|
_project_root: &ProjectPath,
|
|
settings: &ProjectPluginSettings,
|
|
) -> Result<(), domain::StoreError> {
|
|
*self.settings.lock().unwrap() = settings.clone();
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
struct MemoryRegistry {
|
|
registry: PluginRegistry,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl PluginRegistryStore for MemoryRegistry {
|
|
async fn load_registry(&self) -> Result<PluginRegistry, domain::PluginRegistryError> {
|
|
Ok(self.registry.clone())
|
|
}
|
|
|
|
async fn save_registry(
|
|
&self,
|
|
_registry: &PluginRegistry,
|
|
) -> Result<(), domain::PluginRegistryError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
struct MemoryPackages {
|
|
packages: Vec<PluginPackageRef>,
|
|
app_data_dir: Option<String>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl PluginPackageStore for MemoryPackages {
|
|
async fn list_installed(&self) -> Result<Vec<PluginPackageRef>, PluginStoreError> {
|
|
Ok(self.packages.clone())
|
|
}
|
|
|
|
async fn read_manifest(
|
|
&self,
|
|
_package: &PluginPackageRef,
|
|
) -> Result<PluginManifestBytes, PluginStoreError> {
|
|
Ok(PluginManifestBytes { bytes: Vec::new() })
|
|
}
|
|
|
|
async fn install_from_archive(
|
|
&self,
|
|
_archive: &LocalPath,
|
|
) -> Result<StagedPluginPackage, PluginStoreError> {
|
|
Err(PluginStoreError::Invalid("not implemented".to_owned()))
|
|
}
|
|
|
|
async fn install_from_directory(
|
|
&self,
|
|
_dir: &LocalPath,
|
|
) -> Result<StagedPluginPackage, PluginStoreError> {
|
|
Err(PluginStoreError::Invalid("not implemented".to_owned()))
|
|
}
|
|
|
|
async fn commit_install(
|
|
&self,
|
|
_staged: StagedPluginPackage,
|
|
_plugin_id: &PluginId,
|
|
) -> Result<PluginPackageRef, PluginStoreError> {
|
|
Err(PluginStoreError::Invalid("not implemented".to_owned()))
|
|
}
|
|
|
|
async fn remove_package(
|
|
&self,
|
|
_plugin_id: &PluginId,
|
|
) -> Result<RemovalOutcome, PluginStoreError> {
|
|
Err(PluginStoreError::Invalid("not implemented".to_owned()))
|
|
}
|
|
|
|
fn bundle_url(
|
|
&self,
|
|
_plugin_id: &PluginId,
|
|
_entry: &RelativePath,
|
|
_hash: &ContentHash,
|
|
) -> Result<domain::PluginBundleUrl, PluginStoreError> {
|
|
Err(PluginStoreError::Invalid("not implemented".to_owned()))
|
|
}
|
|
|
|
fn app_data_dir_label(&self) -> Option<String> {
|
|
self.app_data_dir.clone()
|
|
}
|
|
}
|
|
|
|
struct FixedManifest(PluginManifest);
|
|
|
|
impl PluginManifestValidator for FixedManifest {
|
|
fn validate(
|
|
&self,
|
|
_bytes: &[u8],
|
|
_package_root: &PluginPackageRef,
|
|
) -> Result<PluginManifest, PluginManifestError> {
|
|
Ok(self.0.clone())
|
|
}
|
|
}
|
|
|
|
fn plugin_id() -> PluginId {
|
|
PluginId::new("com.idea.unity-tools").unwrap()
|
|
}
|
|
|
|
fn agent_id() -> AgentId {
|
|
AgentId::from_uuid(uuid::Uuid::from_u128(1))
|
|
}
|
|
|
|
fn project_root() -> ProjectPath {
|
|
ProjectPath::new("/project").unwrap()
|
|
}
|
|
|
|
fn manifest(auto_start: bool) -> PluginManifest {
|
|
PluginManifest {
|
|
idea_plugin_manifest_version: 1,
|
|
id: plugin_id(),
|
|
display_name: "Unity Tools".to_owned(),
|
|
publisher: None,
|
|
version: PluginVersion::new("1.0.0").unwrap(),
|
|
description: None,
|
|
engine_idea: None,
|
|
main: RelativePath::new("dist/index.js").unwrap(),
|
|
icon: None,
|
|
trust_level: PluginTrustLevel::Full,
|
|
capabilities: vec![PluginCapability::Mcp],
|
|
activation_scope: PluginActivationScope::Project,
|
|
contributes: PluginContributionSet {
|
|
mcp_servers: vec![
|
|
PluginMcpServerContribution {
|
|
id: PluginMcpServerId::new("unity-editor").unwrap(),
|
|
display_name: "Unity Editor".to_owned(),
|
|
command: "bin/server".to_owned(),
|
|
args: vec!["--socket".to_owned(), "${appDataDir}/unity.sock".to_owned()],
|
|
env: vec![("PLUGIN_ROOT".to_owned(), "${pluginRoot}".to_owned())],
|
|
cwd: Some("${pluginRoot}/work".to_owned()),
|
|
transport: "stdio".to_owned(),
|
|
auto_start,
|
|
allow_absolute_command: false,
|
|
tools: Vec::new(),
|
|
},
|
|
PluginMcpServerContribution {
|
|
id: PluginMcpServerId::new("unassigned").unwrap(),
|
|
display_name: "Unassigned".to_owned(),
|
|
command: "bin/other".to_owned(),
|
|
args: Vec::new(),
|
|
env: Vec::new(),
|
|
cwd: None,
|
|
transport: "stdio".to_owned(),
|
|
auto_start: true,
|
|
allow_absolute_command: false,
|
|
tools: Vec::new(),
|
|
},
|
|
],
|
|
..PluginContributionSet::default()
|
|
},
|
|
}
|
|
}
|
|
|
|
fn registry(state: PluginLifecycleState) -> PluginRegistry {
|
|
PluginRegistry {
|
|
version: 1,
|
|
plugins: vec![PluginRegistryEntry {
|
|
id: plugin_id(),
|
|
lifecycle_state: state,
|
|
source: PluginInstallSource::Directory {
|
|
path_label: "/source".to_owned(),
|
|
},
|
|
content_hash: ContentHash::new("abcdef").unwrap(),
|
|
restart_required: false,
|
|
error: None,
|
|
}],
|
|
}
|
|
}
|
|
|
|
fn settings(enabled: bool, tools: Vec<PluginToolRef>) -> ProjectPluginSettings {
|
|
ProjectPluginSettings {
|
|
version: 1,
|
|
plugins: vec![ProjectPluginActivation {
|
|
plugin_id: plugin_id(),
|
|
enabled,
|
|
}],
|
|
agents: vec![AgentPluginAssignments {
|
|
agent_id: agent_id(),
|
|
skills: Vec::new(),
|
|
tools,
|
|
}],
|
|
}
|
|
}
|
|
|
|
fn resolver(
|
|
settings: ProjectPluginSettings,
|
|
registry: PluginRegistry,
|
|
manifest: PluginManifest,
|
|
) -> ProjectPluginResolver {
|
|
ProjectPluginResolver::new(
|
|
Arc::new(MemorySettings {
|
|
settings: Mutex::new(settings),
|
|
}),
|
|
Arc::new(MemoryPackages {
|
|
packages: vec![PluginPackageRef {
|
|
plugin_id: Some(plugin_id()),
|
|
root: "/plugins/unity".to_owned(),
|
|
}],
|
|
app_data_dir: Some("/app-data".to_owned()),
|
|
}),
|
|
Arc::new(MemoryRegistry { registry }),
|
|
Arc::new(FixedManifest(manifest)),
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn assigned_mcp_servers_project_assigned_active_autostart_server_only() {
|
|
let tool = PluginToolRef {
|
|
plugin_id: plugin_id(),
|
|
server_id: PluginMcpServerId::new("unity-editor").unwrap(),
|
|
tool_name: "unity_detect_project".to_owned(),
|
|
};
|
|
let resolver = resolver(
|
|
settings(true, vec![tool]),
|
|
registry(PluginLifecycleState::Enabled),
|
|
manifest(true),
|
|
);
|
|
|
|
let servers = resolver
|
|
.assigned_mcp_servers(&project_root(), agent_id())
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(servers.len(), 1);
|
|
let server = &servers[0];
|
|
assert_eq!(
|
|
server.client_name(),
|
|
"idea_plugin__com_2eidea_2eunity_2dtools__unity_2deditor"
|
|
);
|
|
assert_eq!(server.command, "/plugins/unity/bin/server");
|
|
assert_eq!(
|
|
server.args,
|
|
vec!["--socket".to_owned(), "/app-data/unity.sock".to_owned()]
|
|
);
|
|
assert_eq!(
|
|
server.env,
|
|
vec![("PLUGIN_ROOT".to_owned(), "/plugins/unity".to_owned())]
|
|
);
|
|
assert_eq!(server.cwd, "/plugins/unity/work");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn assigned_mcp_servers_omits_unassigned_disabled_and_non_autostart_servers() {
|
|
let assigned_tool = PluginToolRef {
|
|
plugin_id: plugin_id(),
|
|
server_id: PluginMcpServerId::new("unity-editor").unwrap(),
|
|
tool_name: "unity_detect_project".to_owned(),
|
|
};
|
|
let unassigned_only = resolver(
|
|
settings(true, Vec::new()),
|
|
registry(PluginLifecycleState::Enabled),
|
|
manifest(true),
|
|
);
|
|
assert!(unassigned_only
|
|
.assigned_mcp_servers(&project_root(), agent_id())
|
|
.await
|
|
.unwrap()
|
|
.is_empty());
|
|
|
|
let project_disabled = resolver(
|
|
settings(false, vec![assigned_tool.clone()]),
|
|
registry(PluginLifecycleState::Enabled),
|
|
manifest(true),
|
|
);
|
|
assert!(project_disabled
|
|
.assigned_mcp_servers(&project_root(), agent_id())
|
|
.await
|
|
.unwrap()
|
|
.is_empty());
|
|
|
|
let globally_disabled = resolver(
|
|
settings(true, vec![assigned_tool.clone()]),
|
|
registry(PluginLifecycleState::Disabled),
|
|
manifest(true),
|
|
);
|
|
assert!(globally_disabled
|
|
.assigned_mcp_servers(&project_root(), agent_id())
|
|
.await
|
|
.unwrap()
|
|
.is_empty());
|
|
|
|
let non_autostart = resolver(
|
|
settings(true, vec![assigned_tool]),
|
|
registry(PluginLifecycleState::Enabled),
|
|
manifest(false),
|
|
);
|
|
assert!(non_autostart
|
|
.assigned_mcp_servers(&project_root(), agent_id())
|
|
.await
|
|
.unwrap()
|
|
.is_empty());
|
|
}
|
|
}
|