Files
IdeA/crates/application/src/project_plugin.rs
Blomios a94167ada0 fix(domain,application,infrastructure): borne le namespace MCP plugin à 64 chars pour Codex/OpenAI — #333 (QA verte)
Cause racine: PluginMcpServerSpec::client_name() encodait chaque octet
non alphanumérique en _XX, produisant pour UnitySoundTool
`idea_plugin__com_2eidea_2eunity_2ddeveloper_2dtools__unity_2ddeveloper_2dtools_2etools`
(86 chars). Codex préfixe `mcp__` => 91 chars, limite API 64 => HTTP 400
`Invalid input.namespace string too long` sur chaque `codex exec resume`
dont l'historique contenait un outil Unity, aucun agent_message, UI en
fallback générique « réponse non finalisée ».

- domain: client_name() borné à 59 bytes ASCII (=> 64 avec mcp__) —
  stem lisible (minuscules, non-alnum => '_', tronqué) + suffixe
  déterministe = 16 premiers hex de SHA-256(plugin_id + NUL + server_id)
  pour conserver la résistance aux collisions après troncature; tests
  Unity (59 exact), stabilité, slug ponctuation, collisions sur stems
  tronqués identiques
- application: tests de projection TOML/JSON Codex et Claude adaptés au
  vrai namespace legacy encodé (86 chars) — vérifie que les tables
  utilisateur (mcp_servers.user_server, user-server) sont préservées
  pendant le retrait des tables managées périmées
- infrastructure: parse_event Codex traite turn.failed/error comme
  ReplyEvent::Error avec le message fournisseur (fini le fallback
  générique muet), classification lisible MCP_NAMESPACE_TOO_LONG: pour
  les erreurs de longueur de namespace; tests

Après déploiement: nouvelle conversation/restart requis pour les agents
Codex existants — les historiques contiennent déjà le namespace
invalide. Aucun push.
2026-09-12 07:57:03 +02:00

1086 lines
35 KiB
Rust

//! Project-scoped plugin activation and per-agent capability assignments.
use std::collections::HashSet;
use std::sync::Arc;
use domain::{
AgentContextStore, AgentId, AgentPluginAssignments, DomainEvent, EventBus, 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>,
events: Arc<dyn EventBus>,
}
impl SetProjectPluginEnabled {
/// Builds the use case.
#[must_use]
pub fn new(
store: Arc<dyn ProjectPluginSettingsStore>,
packages: Arc<dyn PluginPackageStore>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
store,
packages,
events,
}
}
/// 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?;
self.events
.publish(DomainEvent::ProjectPluginSettingsChanged {
project_id: project.id,
});
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>,
events: Arc<dyn EventBus>,
}
impl MutateAgentPluginSkill {
/// Builds the use case.
#[must_use]
pub fn new(
store: Arc<dyn ProjectPluginSettingsStore>,
contexts: Arc<dyn AgentContextStore>,
resolver: Arc<ProjectPluginResolver>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
store,
contexts,
resolver,
events,
}
}
/// 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?;
self.events
.publish(DomainEvent::ProjectPluginSettingsChanged {
project_id: project.id,
});
Ok(settings)
}
}
/// Assigns or unassigns one namespaced external MCP tool.
pub struct MutateAgentPluginTool {
store: Arc<dyn ProjectPluginSettingsStore>,
contexts: Arc<dyn AgentContextStore>,
resolver: Arc<ProjectPluginResolver>,
events: Arc<dyn EventBus>,
}
impl MutateAgentPluginTool {
/// Builds the use case.
#[must_use]
pub fn new(
store: Arc<dyn ProjectPluginSettingsStore>,
contexts: Arc<dyn AgentContextStore>,
resolver: Arc<ProjectPluginResolver>,
events: Arc<dyn EventBus>,
) -> Self {
Self {
store,
contexts,
resolver,
events,
}
}
/// 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?;
self.events
.publish(DomainEvent::ProjectPluginSettingsChanged {
project_id: project.id,
});
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::{
Agent, AgentManifest, AgentOrigin, ContentHash, LocalPath, PluginActivationScope,
PluginCapability, PluginContributionSet, PluginInstallSource, PluginLifecycleState,
PluginManifestBytes, PluginManifestError, PluginMcpServerContribution, PluginPackageRef,
PluginRegistry, PluginRegistryEntry, PluginTrustLevel, PluginVersion, ProfileId,
ProjectPath, RelativePath, RemoteRef, RemovalOutcome, StagedPluginPackage,
};
use std::sync::Mutex;
struct MemorySettings {
settings: Mutex<ProjectPluginSettings>,
fail_save: bool,
}
#[derive(Clone, Default)]
struct SpyEvents(Arc<Mutex<Vec<DomainEvent>>>);
impl SpyEvents {
fn events(&self) -> Vec<DomainEvent> {
self.0.lock().unwrap().clone()
}
}
impl EventBus for SpyEvents {
fn publish(&self, event: DomainEvent) {
self.0.lock().unwrap().push(event);
}
fn subscribe(&self) -> domain::EventStream {
Box::new(std::iter::empty())
}
}
struct FakeContexts;
#[async_trait]
impl AgentContextStore for FakeContexts {
async fn read_context(
&self,
_project: &Project,
_agent: &AgentId,
) -> Result<MarkdownDoc, domain::StoreError> {
Ok(MarkdownDoc::new("# Agent"))
}
async fn write_context(
&self,
_project: &Project,
_agent: &AgentId,
_md: &MarkdownDoc,
) -> Result<(), domain::StoreError> {
Ok(())
}
async fn load_manifest(
&self,
_project: &Project,
) -> Result<AgentManifest, domain::StoreError> {
let agent = Agent::new(
agent_id(),
"Dev",
"agents/dev.md",
ProfileId::from_uuid(uuid::Uuid::from_u128(2)),
AgentOrigin::Scratch,
false,
)
.unwrap();
Ok(AgentManifest::new(1, vec![domain::ManifestEntry::from_agent(&agent)]).unwrap())
}
async fn save_manifest(
&self,
_project: &Project,
_manifest: &AgentManifest,
) -> Result<(), domain::StoreError> {
Ok(())
}
}
#[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> {
if self.fail_save {
return Err(domain::StoreError::Io("save failed".to_owned()));
}
*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 project() -> Project {
Project::new(
domain::ProjectId::from_uuid(uuid::Uuid::from_u128(328)),
"Plugin project",
project_root(),
RemoteRef::Local,
0,
)
.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),
fail_save: false,
}),
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_idea_unity_tools__unity___904dacd241fbd4e7"
);
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());
}
#[tokio::test]
async fn project_plugin_activation_publishes_change_after_successful_save() {
let store = Arc::new(MemorySettings {
settings: Mutex::new(ProjectPluginSettings::default()),
fail_save: false,
});
let packages = Arc::new(MemoryPackages {
packages: vec![PluginPackageRef {
plugin_id: Some(plugin_id()),
root: "/plugins/unity".to_owned(),
}],
app_data_dir: None,
});
let events = SpyEvents::default();
let use_case = SetProjectPluginEnabled::new(store, packages, Arc::new(events.clone()));
let project = project();
use_case
.execute(project.clone(), plugin_id(), true)
.await
.unwrap();
assert_eq!(
events.events(),
vec![DomainEvent::ProjectPluginSettingsChanged {
project_id: project.id,
}]
);
}
#[tokio::test]
async fn project_plugin_activation_does_not_publish_when_save_fails() {
let store = Arc::new(MemorySettings {
settings: Mutex::new(ProjectPluginSettings::default()),
fail_save: true,
});
let packages = Arc::new(MemoryPackages {
packages: vec![PluginPackageRef {
plugin_id: Some(plugin_id()),
root: "/plugins/unity".to_owned(),
}],
app_data_dir: None,
});
let events = SpyEvents::default();
let use_case = SetProjectPluginEnabled::new(store, packages, Arc::new(events.clone()));
let error = use_case
.execute(project(), plugin_id(), true)
.await
.unwrap_err();
assert_eq!(error.code(), "STORE");
assert!(events.events().is_empty());
}
#[tokio::test]
async fn project_plugin_assignment_publishes_change_after_successful_save() {
let store = Arc::new(MemorySettings {
settings: Mutex::new(settings(true, Vec::new())),
fail_save: false,
});
let packages = Arc::new(MemoryPackages {
packages: vec![PluginPackageRef {
plugin_id: Some(plugin_id()),
root: "/plugins/unity".to_owned(),
}],
app_data_dir: None,
});
let resolver = Arc::new(ProjectPluginResolver::new(
store.clone(),
packages,
Arc::new(MemoryRegistry {
registry: registry(PluginLifecycleState::Enabled),
}),
Arc::new(FixedManifest(manifest(true))),
));
let events = SpyEvents::default();
let use_case = MutateAgentPluginTool::new(
store,
Arc::new(FakeContexts),
resolver,
Arc::new(events.clone()),
);
let project = project();
use_case
.execute(
project.clone(),
agent_id(),
PluginToolRef {
plugin_id: plugin_id(),
server_id: PluginMcpServerId::new("unity-editor").unwrap(),
tool_name: "unity_detect_project".to_owned(),
},
AssignmentMutation::Assign,
)
.await
.unwrap();
assert_eq!(
events.events(),
vec![DomainEvent::ProjectPluginSettingsChanged {
project_id: project.id,
}]
);
}
}