feat(plugins): hot reload des plugins sans relancer IdeA — use case ReloadPlugin (invalidation registry/catalogue, resultat hashes+changed), tools MCP d'administration plugin agent-facing (mcp/plugins.rs + PluginToolProvider), evenement domaine plugin_reloaded relaye sur le wire (dto/events), exposition openai_tools rafraichie, wiring app-tauri — #271/#269 (QA verte #273: application 32, mcp_server 38, openai_tools 10, plugin_install_load 5, plugin 10, wire 1 — 96 tests ciblés; app-tauri compile sans desktop runtime)

This commit is contained in:
2026-09-07 19:29:25 +02:00
parent 8d2be75f8f
commit 805b1b4d0d
14 changed files with 863 additions and 32 deletions

View File

@ -410,6 +410,7 @@ pub fn run() {
plugins::plugin_review_package, plugins::plugin_review_package,
plugins::plugin_install_from_archive, plugins::plugin_install_from_archive,
plugins::plugin_install_from_directory, plugins::plugin_install_from_directory,
plugins::plugin_reload,
plugins::plugin_set_enabled, plugins::plugin_set_enabled,
plugins::plugin_uninstall, plugins::plugin_uninstall,
plugins::plugin_list_runtime_contributions, plugins::plugin_list_runtime_contributions,

View File

@ -9,12 +9,13 @@ use backend::dto::{
PluginConfigDocumentUpdateDto, PluginConfigDocumentWriteResultDto, PluginEventBatchDto, PluginConfigDocumentUpdateDto, PluginConfigDocumentWriteResultDto, PluginEventBatchDto,
PluginEventPollDto, PluginEventSubscribeDto, PluginEventSubscriptionDto, PluginEventPollDto, PluginEventSubscribeDto, PluginEventSubscriptionDto,
PluginEventUnsubscribeDto, PluginInstallResultDto, PluginProjectStructureDto, PluginEventUnsubscribeDto, PluginInstallResultDto, PluginProjectStructureDto,
PluginProjectStructureQueryDto, PluginReviewDto, PluginRunCommandDto, PluginProjectStructureQueryDto, PluginReloadResultDto, PluginReviewDto, PluginRunCommandDto,
PluginRuntimeContributionCatalogDto, PluginStorageGetDto, PluginStorageSetDto, PluginTaskDto, PluginRuntimeContributionCatalogDto, PluginStorageGetDto, PluginStorageSetDto, PluginTaskDto,
PluginTaskStatusDto, PluginToolchainDiagnosticDto, PluginToolchainDiagnosticRequestDto, PluginTaskStatusDto, PluginToolchainDiagnosticDto, PluginToolchainDiagnosticRequestDto,
PluginUninstallResultDto, PluginWorkspaceBinaryFileDto, PluginWorkspaceDirectoryListingDto, PluginUninstallResultDto, PluginWorkspaceBinaryFileDto, PluginWorkspaceDirectoryListingDto,
PluginWorkspacePathDto, PluginWorkspaceStatDto, PluginWorkspaceTextFileDto, PluginWorkspacePathDto, PluginWorkspaceStatDto, PluginWorkspaceTextFileDto,
PluginWorkspaceWriteBinaryDto, PluginWorkspaceWriteTextDto, ReviewPluginPackageDto, PluginWorkspaceWriteBinaryDto, PluginWorkspaceWriteTextDto, ReloadPluginDto,
ReviewPluginPackageDto,
}; };
use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore}; use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore};
use domain::{PluginId, RelativePath}; use domain::{PluginId, RelativePath};
@ -122,6 +123,20 @@ pub async fn plugin_install_from_directory(
result result
} }
/// Hot-reloads a plugin from its recorded directory source.
#[tauri::command]
pub async fn plugin_reload(
input: ReloadPluginDto,
state: State<'_, AppState>,
) -> Result<PluginReloadResultDto, ErrorDto> {
state
.reload_plugin
.execute(input.into())
.await
.map(PluginReloadResultDto::from)
.map_err(ErrorDto::from)
}
/// Enables or disables a plugin. /// Enables or disables a plugin.
#[tauri::command] #[tauri::command]
pub async fn plugin_set_enabled( pub async fn plugin_set_enabled(

View File

@ -182,17 +182,18 @@ pub use plugin::{
PluginDiagnosticMessage, PluginEnvDiagnostic, PluginEnvRequirement, PluginEventBatch, PluginDiagnosticMessage, PluginEnvDiagnostic, PluginEnvRequirement, PluginEventBatch,
PluginEventPollInput, PluginEventSubscribeInput, PluginEventSubscription, PluginEventPollInput, PluginEventSubscribeInput, PluginEventSubscription,
PluginEventSubscriptions, PluginEventUnsubscribeInput, PluginFileDiagnostic, PluginEventSubscriptions, PluginEventUnsubscribeInput, PluginFileDiagnostic,
PluginFileRequirement, PluginInstallResult, PluginPublicEvent, PluginReview, PluginFileRequirement, PluginInstallResult, PluginPublicEvent, PluginReloadResult,
PluginRunCommandInput, PluginRuntimeCatalog, PluginRuntimePlugin, PluginStorageAccess, PluginReview, PluginRunCommandInput, PluginRuntimeCatalog, PluginRuntimePlugin,
PluginStorageGetInput, PluginStorageSetInput, PluginTaskStatusInput, PluginToolDiagnostic, PluginStorageAccess, PluginStorageGetInput, PluginStorageSetInput, PluginTaskStatusInput,
PluginToolRequirement, PluginToolchainDiagnostic, PluginToolchainDiagnosticInput, PluginToolDiagnostic, PluginToolRequirement, PluginToolchainDiagnostic,
PluginToolchainDiagnostics, PluginWorkspaceAccess, PluginWorkspaceBinaryFile, PluginToolchainDiagnosticInput, PluginToolchainDiagnostics, PluginWorkspaceAccess,
PluginWorkspaceDirEntry, PluginWorkspaceDirectoryListing, PluginWorkspacePathInput, PluginWorkspaceBinaryFile, PluginWorkspaceDirEntry, PluginWorkspaceDirectoryListing,
PluginWorkspaceStat, PluginWorkspaceTextFile, PluginWorkspaceWriteBinaryInput, PluginWorkspacePathInput, PluginWorkspaceStat, PluginWorkspaceTextFile,
PluginWorkspaceWriteTextInput, ProjectConvention, ProjectModule, ProjectStructureEntry, PluginWorkspaceWriteBinaryInput, PluginWorkspaceWriteTextInput, ProjectConvention,
ProjectStructureQuery, QueryProjectStructure, QueryProjectStructureInput, ProjectModule, ProjectStructureEntry, ProjectStructureQuery, QueryProjectStructure,
ReconcilePluginMcpServers, ReviewPluginPackage, ReviewPluginPackageInput, SetPluginEnabled, QueryProjectStructureInput, ReconcilePluginMcpServers, ReloadPlugin, ReloadPluginInput,
SetPluginEnabledInput, UninstallPlugin, UninstallPluginInput, UninstallPluginResult, ReviewPluginPackage, ReviewPluginPackageInput, SetPluginEnabled, SetPluginEnabledInput,
UninstallPlugin, UninstallPluginInput, UninstallPluginResult,
}; };
pub use project::{ pub use project::{
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject, CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,

View File

@ -117,6 +117,36 @@ pub struct PluginInstallResult {
pub restart_required: bool, pub restart_required: bool,
} }
/// Input for reloading an installed plugin from its recorded source.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReloadPluginInput {
/// Installed plugin id.
pub plugin_id: String,
/// Optional optimistic-concurrency guard against the installed content hash.
pub expected_content_hash: Option<String>,
}
/// Result of reloading an installed plugin package.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginReloadResult {
/// Reloaded plugin admin projection.
pub plugin: PluginAdmin,
/// Content hash before the reload.
pub old_content_hash: String,
/// Content hash after the reload.
pub new_content_hash: String,
/// Whether the package contents changed.
pub changed: bool,
/// Reloaded runtime catalogue entry when the plugin is runtime-active.
pub runtime_catalog_entry: Option<PluginRuntimePlugin>,
/// MCP server state after reconciliation.
pub mcp_statuses: domain::PluginMcpStatusSet,
/// Non-fatal post-commit warnings.
pub warnings: Vec<String>,
}
/// Uninstall result. /// Uninstall result.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@ -2396,6 +2426,171 @@ impl InstallPluginFromDirectory {
} }
} }
/// Reloads an installed directory plugin without restarting IdeA.
pub struct ReloadPlugin {
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
events: Arc<dyn EventBus>,
mcp: Arc<dyn PluginMcpSupervisor>,
}
impl ReloadPlugin {
/// Builds the use case.
#[must_use]
pub fn new(
packages: Arc<dyn PluginPackageStore>,
registry: Arc<dyn PluginRegistryStore>,
validator: Arc<dyn PluginManifestValidator>,
events: Arc<dyn EventBus>,
mcp: Arc<dyn PluginMcpSupervisor>,
) -> Self {
Self {
packages,
registry,
validator,
events,
mcp,
}
}
/// Executes the use case from the source persisted in the plugin registry.
pub async fn execute(&self, input: ReloadPluginInput) -> Result<PluginReloadResult, AppError> {
let plugin_id =
PluginId::new(input.plugin_id).map_err(|e| AppError::Invalid(e.to_string()))?;
let mut registry = self.registry.load_registry().await.map_err(map_registry)?;
let old_entry = registry
.find(&plugin_id)
.cloned()
.ok_or_else(|| AppError::NotFound("plugin".to_owned()))?;
let old_content_hash = old_entry.content_hash.as_str().to_owned();
if let Some(expected) = input.expected_content_hash {
if expected != old_content_hash {
return Err(AppError::Conflict(old_content_hash));
}
}
let source_path = match &old_entry.source {
PluginInstallSource::Directory { path_label } => path_label.clone(),
PluginInstallSource::Archive { .. } => {
return Err(AppError::Invalid(format!(
"plugin `{}` cannot be reloaded: archive install sources are unsupported",
plugin_id.as_str()
)))
}
};
let staged = self
.packages
.install_from_directory(&LocalPath::new(source_path))
.await
.map_err(map_store)?;
let review =
review_staged(self.packages.as_ref(), self.validator.as_ref(), &staged).await?;
if review.manifest.id != plugin_id {
return Err(AppError::Invalid(format!(
"reload manifest id `{}` does not match installed plugin `{}`",
review.manifest.id.as_str(),
plugin_id.as_str()
)));
}
let new_content_hash = review.content_hash.clone();
let changed = old_content_hash != new_content_hash;
self.packages
.commit_install(staged, &plugin_id)
.await
.map_err(map_store)?;
let mut entry = PluginRegistryEntry {
content_hash: ContentHash::new(new_content_hash.clone())
.map_err(|e| AppError::Invalid(e.to_string()))?,
restart_required: false,
error: None,
..old_entry
};
let runtime_catalog_entry = if entry.lifecycle_state.is_runtime_active() {
match runtime_plugin_from_entry(
self.packages.as_ref(),
self.validator.as_ref(),
entry.clone(),
)
.await
{
Ok(runtime) => Some(runtime),
Err(err) => {
entry.lifecycle_state = PluginLifecycleState::Invalid;
entry.error = Some(format!(
"runtime contributions disabled after reload: {err}"
));
None
}
}
} else {
None
};
registry.upsert(entry.clone());
self.registry
.save_registry(&registry)
.await
.map_err(map_registry)?;
self.events.publish(DomainEvent::PluginReloaded {
plugin_id: plugin_id.clone(),
old_content_hash: old_content_hash.clone(),
new_content_hash: new_content_hash.clone(),
changed,
});
let mut warnings = Vec::new();
let mcp_statuses = match active_mcp_specs(
self.packages.as_ref(),
self.validator.as_ref(),
&registry,
)
.await
{
Ok((specs, invalid_plugins)) => {
persist_invalid_runtime_plugins(
self.registry.as_ref(),
&mut registry,
invalid_plugins,
)
.await;
match self.mcp.reconcile(specs).await {
Ok(statuses) => statuses,
Err(err) => {
warnings.push(format!("MCP reconcile failed after reload: {err}"));
domain::PluginMcpStatusSet::default()
}
}
}
Err(err) => {
warnings.push(format!(
"MCP reconcile preparation failed after reload: {err}"
));
domain::PluginMcpStatusSet::default()
}
};
let plugin = admin_from_descriptor(
PluginDescriptor {
manifest: review.manifest,
registry: entry,
},
self.packages.as_ref(),
)?;
Ok(PluginReloadResult {
plugin,
old_content_hash,
new_content_hash,
changed,
runtime_catalog_entry,
mcp_statuses,
warnings,
})
}
}
async fn install_from_staged( async fn install_from_staged(
packages: &dyn PluginPackageStore, packages: &dyn PluginPackageStore,
registry_store: &dyn PluginRegistryStore, registry_store: &dyn PluginRegistryStore,
@ -3497,6 +3692,14 @@ mod tests {
} }
fn with_manifest_and_staged_count(bytes: Vec<u8>, staged_count: usize) -> Self { fn with_manifest_and_staged_count(bytes: Vec<u8>, staged_count: usize) -> Self {
Self::with_manifest_staged_count_and_hash(bytes, staged_count, "abc123")
}
fn with_manifest_staged_count_and_hash(
bytes: Vec<u8>,
staged_count: usize,
staged_hash: &str,
) -> Self {
let mut manifests = HashMap::new(); let mut manifests = HashMap::new();
manifests.insert("dev.acme.gitgraph".to_owned(), bytes); manifests.insert("dev.acme.gitgraph".to_owned(), bytes);
let staged = (0..staged_count) let staged = (0..staged_count)
@ -3505,7 +3708,7 @@ mod tests {
source: PluginInstallSource::Directory { source: PluginInstallSource::Directory {
path_label: "/source/plugin".to_owned(), path_label: "/source/plugin".to_owned(),
}, },
content_hash: content_hash("abc123"), content_hash: content_hash(staged_hash),
}) })
.collect(); .collect();
Self { Self {
@ -3971,6 +4174,160 @@ mod tests {
assert_eq!(reconciles[0][0].cwd, "/installed/dev.acme.gitgraph"); assert_eq!(reconciles[0][0].cwd, "/installed/dev.acme.gitgraph");
} }
#[tokio::test]
async fn reload_directory_plugin_updates_hash_publishes_event_and_reconciles_mcp() {
let packages = Arc::new(FakePackages::with_manifest_staged_count_and_hash(
valid_manifest(),
1,
"def456",
));
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
});
let events = Arc::new(FakeEvents::default());
let mcp = Arc::new(FakeMcp::default());
let usecase = ReloadPlugin::new(
packages,
registry.clone(),
Arc::new(validator()),
events.clone(),
mcp.clone(),
);
let result = usecase
.execute(ReloadPluginInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
expected_content_hash: Some("abc123".to_owned()),
})
.await
.unwrap();
assert!(result.changed);
assert_eq!(result.old_content_hash, "abc123");
assert_eq!(result.new_content_hash, "def456");
assert!(result.runtime_catalog_entry.is_some());
assert_eq!(result.mcp_statuses.servers.len(), 1);
let saved = registry.load_registry().await.unwrap();
let entry = saved.find(&plugin_id()).unwrap();
assert_eq!(entry.content_hash.as_str(), "def456");
assert!(!entry.restart_required);
assert_eq!(mcp.reconciles.lock().unwrap().len(), 1);
assert_eq!(
events.events.lock().unwrap().as_slice(),
&[DomainEvent::PluginReloaded {
plugin_id: plugin_id(),
old_content_hash: "abc123".to_owned(),
new_content_hash: "def456".to_owned(),
changed: true,
}]
);
}
#[tokio::test]
async fn reload_with_identical_hash_is_idempotent_but_still_reconciles_mcp() {
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
});
let events = Arc::new(FakeEvents::default());
let mcp = Arc::new(FakeMcp::default());
let usecase = ReloadPlugin::new(
packages,
registry,
Arc::new(validator()),
events.clone(),
mcp.clone(),
);
let result = usecase
.execute(ReloadPluginInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
expected_content_hash: None,
})
.await
.unwrap();
assert!(!result.changed);
assert_eq!(result.old_content_hash, "abc123");
assert_eq!(result.new_content_hash, "abc123");
assert_eq!(mcp.reconciles.lock().unwrap().len(), 1);
assert_eq!(
events.events.lock().unwrap().as_slice(),
&[DomainEvent::PluginReloaded {
plugin_id: plugin_id(),
old_content_hash: "abc123".to_owned(),
new_content_hash: "abc123".to_owned(),
changed: false,
}]
);
}
#[tokio::test]
async fn reload_rejects_stale_hash_before_staging_package() {
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
});
let events = Arc::new(FakeEvents::default());
let mcp = Arc::new(FakeMcp::default());
let usecase = ReloadPlugin::new(
packages.clone(),
registry,
Arc::new(validator()),
events.clone(),
mcp.clone(),
);
let error = usecase
.execute(ReloadPluginInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
expected_content_hash: Some("stale".to_owned()),
})
.await
.unwrap_err();
assert_eq!(error, AppError::Conflict("abc123".to_owned()));
assert_eq!(packages.staged.lock().unwrap().len(), 1);
assert!(events.events.lock().unwrap().is_empty());
assert!(mcp.reconciles.lock().unwrap().is_empty());
}
#[tokio::test]
async fn reload_rejects_archive_source() {
let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));
let mut seeded = registry_with(PluginLifecycleState::Enabled);
seeded.plugins[0].source = PluginInstallSource::Archive {
path_label: "/source/plugin.zip".to_owned(),
};
let registry = Arc::new(FakeRegistry {
registry: Mutex::new(seeded),
});
let events = Arc::new(FakeEvents::default());
let mcp = Arc::new(FakeMcp::default());
let usecase = ReloadPlugin::new(
packages.clone(),
registry,
Arc::new(validator()),
events.clone(),
mcp.clone(),
);
let error = usecase
.execute(ReloadPluginInput {
plugin_id: "dev.acme.gitgraph".to_owned(),
expected_content_hash: None,
})
.await
.unwrap_err();
assert!(
matches!(error, AppError::Invalid(message) if message.contains("archive install sources are unsupported"))
);
assert_eq!(packages.staged.lock().unwrap().len(), 1);
assert!(events.events.lock().unwrap().is_empty());
assert!(mcp.reconciles.lock().unwrap().is_empty());
}
#[tokio::test] #[tokio::test]
async fn reconcile_mcp_does_not_spawn_pending_uninstall_plugin_servers() { async fn reconcile_mcp_does_not_spawn_pending_uninstall_plugin_servers() {
let packages = Arc::new(FakePackages::with_manifest(valid_manifest())); let packages = Arc::new(FakePackages::with_manifest(valid_manifest()));

View File

@ -199,6 +199,59 @@ impl From<application::PluginInstallResult> for PluginInstallResultDto {
} }
} }
/// Plugin reload request DTO.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReloadPluginDto {
/// Installed plugin id.
pub plugin_id: String,
/// Optional installed-hash concurrency guard.
pub expected_content_hash: Option<String>,
}
impl From<ReloadPluginDto> for application::ReloadPluginInput {
fn from(value: ReloadPluginDto) -> Self {
Self {
plugin_id: value.plugin_id,
expected_content_hash: value.expected_content_hash,
}
}
}
/// Plugin hot-reload result DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginReloadResultDto {
/// Reloaded plugin.
pub plugin: PluginAdminDto,
/// Previous content hash.
pub old_content_hash: String,
/// New content hash.
pub new_content_hash: String,
/// Whether the content changed.
pub changed: bool,
/// Runtime catalogue entry when active.
pub runtime_catalog_entry: Option<application::PluginRuntimePlugin>,
/// MCP status after reconciliation.
pub mcp_statuses: domain::PluginMcpStatusSet,
/// Non-fatal warnings.
pub warnings: Vec<String>,
}
impl From<application::PluginReloadResult> for PluginReloadResultDto {
fn from(value: application::PluginReloadResult) -> Self {
Self {
plugin: value.plugin.into(),
old_content_hash: value.old_content_hash,
new_content_hash: value.new_content_hash,
changed: value.changed,
runtime_catalog_entry: value.runtime_catalog_entry,
mcp_statuses: value.mcp_statuses,
warnings: value.warnings,
}
}
}
/// Plugin uninstall result DTO. /// Plugin uninstall result DTO.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]

View File

@ -127,6 +127,18 @@ pub enum DomainEventDto {
/// Installed version. /// Installed version.
version: String, version: String,
}, },
/// A plugin package was hot-reloaded.
#[serde(rename_all = "camelCase")]
PluginReloaded {
/// Plugin id.
plugin_id: String,
/// Previous content hash.
old_content_hash: String,
/// New content hash.
new_content_hash: String,
/// Whether the content changed.
changed: bool,
},
/// A plugin was enabled. /// A plugin was enabled.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
PluginEnabled { PluginEnabled {
@ -782,6 +794,17 @@ impl From<&DomainEvent> for DomainEventDto {
plugin_id: plugin_id.to_string(), plugin_id: plugin_id.to_string(),
version: version.as_str().to_owned(), version: version.as_str().to_owned(),
}, },
DomainEvent::PluginReloaded {
plugin_id,
old_content_hash,
new_content_hash,
changed,
} => Self::PluginReloaded {
plugin_id: plugin_id.to_string(),
old_content_hash: old_content_hash.clone(),
new_content_hash: new_content_hash.clone(),
changed: *changed,
},
DomainEvent::PluginEnabled { plugin_id } => Self::PluginEnabled { DomainEvent::PluginEnabled { plugin_id } => Self::PluginEnabled {
plugin_id: plugin_id.to_string(), plugin_id: plugin_id.to_string(),
}, },
@ -1327,6 +1350,24 @@ mod tests {
assert_eq!(json["status"]["reused"], true); assert_eq!(json["status"]["reused"], true);
} }
#[test]
fn plugin_reloaded_relays_hashes_and_changed_flag_to_wire() {
let plugin_id = domain::PluginId::new("dev.acme.gitgraph").unwrap();
let dto = DomainEventDto::from(&DomainEvent::PluginReloaded {
plugin_id,
old_content_hash: "abc123".to_owned(),
new_content_hash: "def456".to_owned(),
changed: true,
});
let json = serde_json::to_value(&dto).expect("serialisable");
assert_eq!(json["type"], "pluginReloaded");
assert_eq!(json["pluginId"], "dev.acme.gitgraph");
assert_eq!(json["oldContentHash"], "abc123");
assert_eq!(json["newContentHash"], "def456");
assert_eq!(json["changed"], true);
}
#[test] #[test]
fn model_server_status_changed_relays_downloading_to_dto_and_wire() { fn model_server_status_changed_relays_downloading_to_dto_and_wire() {
let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged { let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged {

View File

@ -44,21 +44,22 @@ use application::{
ReadIssue, ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadIssue, ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory,
ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn, ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn,
RecordTurnProvider, ReferenceProfiles, ReleaseManagedTemp, RenameDevice, RenameLayout, RecordTurnProvider, ReferenceProfiles, ReleaseManagedTemp, ReloadPlugin, ReloadPluginInput,
RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentCapabilities, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal,
ResolveAgentPermissions, ResolveAgentSystemPermissions, ResolveManagedTemp, ResolveMemoryLinks, ResolveAgentCapabilities, ResolveAgentPermissions, ResolveAgentSystemPermissions,
RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage, RevokeAllDevices, RevokeDevice, ResolveManagedTemp, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask,
RotateConversationLog, SaveChatDraft, SaveEmbedderProfile, SaveModelServer, ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft,
SaveOpenCodeProviderProfile, SaveProfile, SessionLimitService, SetActiveLayout, SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile,
SetPluginEnabled, SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents, SessionLimitService, SetActiveLayout, SetPluginEnabled, SlashCommandRegistry,
SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent,
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice, StructuredRoutingMode, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate,
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, TerminalSessions, TouchDevice, UnassignSkillFromAgent, UnassignTicketFromSprint,
UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UninstallPlugin, UnlinkIssues, UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort,
UpdateAgentPermissions, UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions,
UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
UpdateProjectPermissions, UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions,
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal,
AGENT_MEMORY_RECALL_BUDGET,
}; };
use async_trait::async_trait; use async_trait::async_trait;
use domain::ports::{ use domain::ports::{
@ -124,6 +125,7 @@ pub use infrastructure::{acquire_app_data_dir_lock, AppDataDirLock, AppDataDirLo
use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint}; use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint};
use crate::openai_tools::{AppOpenAiToolInvoker, LateBoundOpenAiToolInvoker}; use crate::openai_tools::{AppOpenAiToolInvoker, LateBoundOpenAiToolInvoker};
use infrastructure::orchestrator::mcp::{PluginToolError, PluginToolProvider};
use infrastructure::StdioTransport; use infrastructure::StdioTransport;
use interprocess::local_socket::tokio::Listener as LocalSocketListener; use interprocess::local_socket::tokio::Listener as LocalSocketListener;
@ -143,6 +145,51 @@ fn spawn_detached(future: impl std::future::Future<Output = ()> + Send + 'static
}); });
} }
struct AppPluginToolProvider {
reload: Arc<ReloadPlugin>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct PluginReloadToolArgs {
plugin_id: String,
expected_content_hash: Option<String>,
}
#[async_trait]
impl PluginToolProvider for AppPluginToolProvider {
async fn handle_plugin_tool(
&self,
_project: &Project,
_requester: &str,
name: &str,
arguments: Value,
) -> Result<Value, PluginToolError> {
if name != "idea_plugin_reload" {
return Err(PluginToolError::new(
"NOT_FOUND",
format!("unknown plugin tool: {name}"),
));
}
let args: PluginReloadToolArgs = serde_json::from_value(arguments)
.map_err(|err| PluginToolError::new("INVALID", format!("invalid arguments: {err}")))?;
let result = self
.reload
.execute(ReloadPluginInput {
plugin_id: args.plugin_id,
expected_content_hash: args.expected_content_hash,
})
.await
.map_err(|err| PluginToolError::new(err.code(), err.to_string()))?;
serde_json::to_value(result).map_err(|err| {
PluginToolError::new(
"INTERNAL",
format!("failed to serialize plugin reload result: {err}"),
)
})
}
}
#[cfg(test)] #[cfg(test)]
mod backend_core_build_tests { mod backend_core_build_tests {
use super::BackendCore; use super::BackendCore;
@ -1224,6 +1271,8 @@ pub struct BackendCore {
pub(crate) template_tool_provider: Arc<dyn TemplateToolProvider>, pub(crate) template_tool_provider: Arc<dyn TemplateToolProvider>,
/// MCP provider for the public `idea_managed_temp_*` tools. /// MCP provider for the public `idea_managed_temp_*` tools.
pub(crate) managed_temp_tool_provider: Arc<dyn ManagedTempToolProvider>, pub(crate) managed_temp_tool_provider: Arc<dyn ManagedTempToolProvider>,
/// MCP provider for public plugin administration tools.
pub(crate) plugin_tool_provider: Arc<dyn PluginToolProvider>,
/// Open an ephemeral AI assistant chat bound to one ticket. /// Open an ephemeral AI assistant chat bound to one ticket.
pub open_ticket_assistant: Arc<OpenTicketAssistant>, pub open_ticket_assistant: Arc<OpenTicketAssistant>,
/// Close an ephemeral AI assistant chat bound to one ticket. /// Close an ephemeral AI assistant chat bound to one ticket.
@ -1324,6 +1373,8 @@ pub struct BackendCore {
pub install_plugin_from_archive: Arc<InstallPluginFromArchive>, pub install_plugin_from_archive: Arc<InstallPluginFromArchive>,
/// Install a plugin directory snapshot. /// Install a plugin directory snapshot.
pub install_plugin_from_directory: Arc<InstallPluginFromDirectory>, pub install_plugin_from_directory: Arc<InstallPluginFromDirectory>,
/// Hot-reload an installed plugin from its recorded directory source.
pub reload_plugin: Arc<ReloadPlugin>,
/// List installed plugins for admin UI. /// List installed plugins for admin UI.
pub list_plugins: Arc<ListPlugins>, pub list_plugins: Arc<ListPlugins>,
/// Enable or disable a plugin. /// Enable or disable a plugin.
@ -2704,6 +2755,16 @@ impl BackendCore {
Arc::clone(&events_port), Arc::clone(&events_port),
Arc::clone(&plugin_mcp_supervisor_port), Arc::clone(&plugin_mcp_supervisor_port),
)); ));
let reload_plugin = Arc::new(ReloadPlugin::new(
Arc::clone(&plugin_package_store),
Arc::clone(&plugin_registry_store),
Arc::clone(&plugin_manifest_validator),
Arc::clone(&events_port),
Arc::clone(&plugin_mcp_supervisor_port),
));
let plugin_tool_provider: Arc<dyn PluginToolProvider> = Arc::new(AppPluginToolProvider {
reload: Arc::clone(&reload_plugin),
});
let set_plugin_enabled = Arc::new(SetPluginEnabled::new( let set_plugin_enabled = Arc::new(SetPluginEnabled::new(
Arc::clone(&plugin_package_store), Arc::clone(&plugin_package_store),
Arc::clone(&plugin_registry_store), Arc::clone(&plugin_registry_store),
@ -3147,6 +3208,7 @@ impl BackendCore {
Arc::clone(&ticket_tool_provider), Arc::clone(&ticket_tool_provider),
Arc::clone(&template_tool_provider), Arc::clone(&template_tool_provider),
Arc::clone(&managed_temp_tool_provider), Arc::clone(&managed_temp_tool_provider),
Arc::clone(&plugin_tool_provider),
)) as Arc<dyn ToolInvoker>); )) as Arc<dyn ToolInvoker>);
let stop_live_agent = Arc::new( let stop_live_agent = Arc::new(
@ -3364,6 +3426,7 @@ impl BackendCore {
review_plugin_package, review_plugin_package,
install_plugin_from_archive, install_plugin_from_archive,
install_plugin_from_directory, install_plugin_from_directory,
reload_plugin,
list_plugins, list_plugins,
set_plugin_enabled, set_plugin_enabled,
uninstall_plugin, uninstall_plugin,
@ -3382,6 +3445,7 @@ impl BackendCore {
ticket_tool_binder, ticket_tool_binder,
template_tool_binder, template_tool_binder,
managed_temp_tool_binder, managed_temp_tool_binder,
plugin_tool_provider,
} }
} }
@ -3474,6 +3538,7 @@ impl BackendCore {
.with_ticket_tools(Arc::clone(&self.ticket_tool_provider)) .with_ticket_tools(Arc::clone(&self.ticket_tool_provider))
.with_template_tools(Arc::clone(&self.template_tool_provider)) .with_template_tools(Arc::clone(&self.template_tool_provider))
.with_managed_temp_tools(Arc::clone(&self.managed_temp_tool_provider)) .with_managed_temp_tools(Arc::clone(&self.managed_temp_tool_provider))
.with_plugin_tools(Arc::clone(&self.plugin_tool_provider))
.with_tool_policies(Arc::clone(&self.tool_policy_registry)) .with_tool_policies(Arc::clone(&self.tool_policy_registry))
.with_mcp_tool_permissions(Arc::clone(&self.mcp_tool_permission_store)), .with_mcp_tool_permissions(Arc::clone(&self.mcp_tool_permission_store)),
endpoint, endpoint,

View File

@ -14,6 +14,7 @@ use domain::ports::{
ToolSpec, ToolSpec,
}; };
use domain::{AgentToolPolicy, IssueRef, McpToolPolicy, Project}; use domain::{AgentToolPolicy, IssueRef, McpToolPolicy, Project};
use infrastructure::orchestrator::mcp::PluginToolProvider;
use infrastructure::{ManagedTempToolProvider, TemplateToolProvider, TicketToolProvider}; use infrastructure::{ManagedTempToolProvider, TemplateToolProvider, TicketToolProvider};
use serde_json::Value; use serde_json::Value;
@ -29,6 +30,7 @@ pub struct AppOpenAiToolInvoker {
ticket_tools: Arc<dyn TicketToolProvider>, ticket_tools: Arc<dyn TicketToolProvider>,
template_tools: Arc<dyn TemplateToolProvider>, template_tools: Arc<dyn TemplateToolProvider>,
managed_temp_tools: Arc<dyn ManagedTempToolProvider>, managed_temp_tools: Arc<dyn ManagedTempToolProvider>,
plugin_tools: Arc<dyn PluginToolProvider>,
} }
/// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la /// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la
@ -103,6 +105,7 @@ impl AppOpenAiToolInvoker {
ticket_tools: Arc<dyn TicketToolProvider>, ticket_tools: Arc<dyn TicketToolProvider>,
template_tools: Arc<dyn TemplateToolProvider>, template_tools: Arc<dyn TemplateToolProvider>,
managed_temp_tools: Arc<dyn ManagedTempToolProvider>, managed_temp_tools: Arc<dyn ManagedTempToolProvider>,
plugin_tools: Arc<dyn PluginToolProvider>,
) -> Self { ) -> Self {
Self { Self {
orchestrator, orchestrator,
@ -112,6 +115,7 @@ impl AppOpenAiToolInvoker {
ticket_tools, ticket_tools,
template_tools, template_tools,
managed_temp_tools, managed_temp_tools,
plugin_tools,
} }
} }
} }
@ -232,6 +236,19 @@ impl ToolInvoker for AppOpenAiToolInvoker {
ToolInvocationError::Execution(format!("JSON managed temp tool: {e}")) ToolInvocationError::Execution(format!("JSON managed temp tool: {e}"))
}); });
} }
if infrastructure::orchestrator::mcp::tools::is_plugin_tool(name) {
let value = self
.plugin_tools
.handle_plugin_tool(&project, &requester, name, value)
.await
.map_err(|e| {
let detail =
serde_json::to_string(&e.to_value()).unwrap_or_else(|_| e.to_string());
ToolInvocationError::Execution(detail)
})?;
return serde_json::to_string(&value)
.map_err(|e| ToolInvocationError::Execution(format!("JSON plugin tool: {e}")));
}
let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, &requester) let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, &requester)
.map_err(|e| match e { .map_err(|e| match e {
infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => { infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => {
@ -424,6 +441,7 @@ mod tests {
AgentId, AgentMcpToolPolicyOverride, McpToolPolicy, Project, ProjectId, AgentId, AgentMcpToolPolicyOverride, McpToolPolicy, Project, ProjectId,
ProjectMcpToolPermissions, ProjectPath, RemoteRef, StoreError, Workspace, ProjectMcpToolPermissions, ProjectPath, RemoteRef, StoreError, Workspace,
}; };
use infrastructure::orchestrator::mcp::PluginToolError;
use infrastructure::{ManagedTempToolError, TemplateToolError, TicketToolError}; use infrastructure::{ManagedTempToolError, TemplateToolError, TicketToolError};
use serde_json::json; use serde_json::json;
use uuid::Uuid; use uuid::Uuid;
@ -606,6 +624,36 @@ mod tests {
Arc::new(FakeManagedTempTools::default()) Arc::new(FakeManagedTempTools::default())
} }
#[derive(Default)]
struct FakePluginTools {
calls: Mutex<Vec<(String, String, Value)>>,
}
#[async_trait]
impl PluginToolProvider for FakePluginTools {
async fn handle_plugin_tool(
&self,
_project: &Project,
requester: &str,
name: &str,
arguments: Value,
) -> Result<Value, PluginToolError> {
self.calls.lock().unwrap().push((
requester.to_owned(),
name.to_owned(),
arguments.clone(),
));
Ok(json!({
"pluginId": arguments.get("pluginId").and_then(Value::as_str).unwrap_or_default(),
"changed": true,
}))
}
}
fn fake_plugin_tools() -> Arc<FakePluginTools> {
Arc::new(FakePluginTools::default())
}
fn issue_ref(raw: &str) -> IssueRef { fn issue_ref(raw: &str) -> IssueRef {
IssueRef::from_str(raw).unwrap() IssueRef::from_str(raw).unwrap()
} }
@ -772,6 +820,7 @@ mod tests {
ticket_tools.clone(), ticket_tools.clone(),
template_tools, template_tools,
fake_managed_temp_tools(), fake_managed_temp_tools(),
fake_plugin_tools(),
); );
let denied = invoker let denied = invoker
@ -838,6 +887,7 @@ mod tests {
ticket_tools.clone(), ticket_tools.clone(),
template_tools.clone(), template_tools.clone(),
fake_managed_temp_tools(), fake_managed_temp_tools(),
fake_plugin_tools(),
); );
for tool in ["idea_ticket_list", "idea_template_list"] { for tool in ["idea_ticket_list", "idea_template_list"] {
@ -928,6 +978,7 @@ mod tests {
Arc::new(FakeTicketTools::default()), Arc::new(FakeTicketTools::default()),
Arc::new(FakeTemplateTools::default()), Arc::new(FakeTemplateTools::default()),
fake_managed_temp_tools(), fake_managed_temp_tools(),
fake_plugin_tools(),
); );
let tools = invoker let tools = invoker
@ -968,6 +1019,7 @@ mod tests {
Arc::new(FakeTicketTools::default()), Arc::new(FakeTicketTools::default()),
Arc::new(FakeTemplateTools::default()), Arc::new(FakeTemplateTools::default()),
fake_managed_temp_tools(), fake_managed_temp_tools(),
fake_plugin_tools(),
); );
let tools = invoker let tools = invoker
@ -1001,6 +1053,7 @@ mod tests {
ticket_tools.clone(), ticket_tools.clone(),
template_tools, template_tools,
fake_managed_temp_tools(), fake_managed_temp_tools(),
fake_plugin_tools(),
); );
let result = invoker let result = invoker
@ -1048,6 +1101,7 @@ mod tests {
ticket_tools, ticket_tools,
template_tools.clone(), template_tools.clone(),
fake_managed_temp_tools(), fake_managed_temp_tools(),
fake_plugin_tools(),
); );
let result = invoker let result = invoker
@ -1095,4 +1149,51 @@ mod tests {
let _ = std::fs::remove_dir_all(temp); let _ = std::fs::remove_dir_all(temp);
} }
#[tokio::test]
async fn openai_explicit_plugin_reload_permission_routes_to_plugin_provider() {
let temp = std::env::temp_dir().join(format!(
"idea-openai-plugin-reload-permissions-{}",
Uuid::new_v4()
));
let core = crate::BackendCore::build(temp.clone());
let agent = AgentId::from_uuid(Uuid::from_u128(271));
let requester = agent.to_string();
let plugin_tools = fake_plugin_tools();
let invoker = AppOpenAiToolInvoker::new(
Arc::clone(&core.orchestrator_service),
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
Arc::new(FakePolicies::default()),
mcp_permissions(allow_doc(agent, &["idea_plugin_reload"])),
Arc::new(FakeTicketTools::default()),
Arc::new(FakeTemplateTools::default()),
fake_managed_temp_tools(),
plugin_tools.clone(),
);
let result = invoker
.call(
"idea_plugin_reload",
&json!({
PROJECT_ROOT_ARG: "/tmp/project",
REQUESTER_ARG: requester,
"pluginId": "dev.acme.gitgraph",
"expectedContentHash": "abc123",
})
.to_string(),
)
.await
.expect("explicit durable override should reach plugin provider");
let result: Value = serde_json::from_str(&result).unwrap();
assert_eq!(result["pluginId"], "dev.acme.gitgraph");
assert_eq!(result["changed"], true);
let calls = plugin_tools.calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, requester);
assert_eq!(calls[0].1, "idea_plugin_reload");
assert_eq!(calls[0].2["expectedContentHash"], "abc123");
let _ = std::fs::remove_dir_all(temp);
}
} }

View File

@ -61,6 +61,17 @@ pub enum DomainEvent {
/// Installed version. /// Installed version.
version: PluginVersion, version: PluginVersion,
}, },
/// An installed plugin package was reloaded from its recorded source.
PluginReloaded {
/// Plugin id.
plugin_id: PluginId,
/// Content hash before the reload.
old_content_hash: String,
/// Content hash after the reload.
new_content_hash: String,
/// Whether the package contents changed.
changed: bool,
},
/// A plugin was enabled. /// A plugin was enabled.
PluginEnabled { PluginEnabled {
/// Plugin id. /// Plugin id.

View File

@ -30,6 +30,7 @@
pub mod jsonrpc; pub mod jsonrpc;
pub mod managed_temp; pub mod managed_temp;
pub mod plugins;
pub mod policy; pub mod policy;
pub mod server; pub mod server;
pub mod templates; pub mod templates;
@ -41,6 +42,7 @@ pub use jsonrpc::{
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION,
}; };
pub use managed_temp::{ManagedTempToolError, ManagedTempToolProvider}; pub use managed_temp::{ManagedTempToolError, ManagedTempToolProvider};
pub use plugins::{PluginToolError, PluginToolProvider};
pub use policy::ToolPolicyRegistry; pub use policy::ToolPolicyRegistry;
pub use server::McpServer; pub use server::McpServer;
pub use templates::{TemplateToolError, TemplateToolProvider}; pub use templates::{TemplateToolError, TemplateToolProvider};

View File

@ -0,0 +1,71 @@
//! Public MCP plugin administration tools.
use async_trait::async_trait;
use domain::Project;
use serde_json::{json, Value};
use super::tools::ToolDef;
/// Error returned by an MCP plugin tool provider.
#[derive(Debug, Clone, thiserror::Error)]
#[error("{code}: {message}")]
pub struct PluginToolError {
/// Stable machine-readable code.
pub code: &'static str,
/// Human-readable message.
pub message: String,
}
impl PluginToolError {
/// Builds a typed plugin-tool error.
#[must_use]
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
/// Serialises the error as a camelCase JSON object.
#[must_use]
pub fn to_value(&self) -> Value {
json!({ "code": self.code, "message": self.message })
}
}
/// Provider injected by the composition root to execute public plugin tools.
#[async_trait]
pub trait PluginToolProvider: Send + Sync {
/// Executes one public plugin tool.
async fn handle_plugin_tool(
&self,
project: &Project,
requester: &str,
name: &str,
arguments: Value,
) -> Result<Value, PluginToolError>;
}
/// Returns true when `name` is a public plugin tool.
#[must_use]
pub fn is_plugin_tool(name: &str) -> bool {
name == "idea_plugin_reload"
}
/// Public plugin tool definitions advertised by `tools/list`.
#[must_use]
pub fn catalogue() -> Vec<ToolDef> {
vec![ToolDef {
name: "idea_plugin_reload",
description: "Hot-reload an installed directory plugin from its recorded source.",
input_schema: json!({
"type": "object",
"properties": {
"pluginId": { "type": "string" },
"expectedContentHash": { "type": "string" }
},
"required": ["pluginId"],
"additionalProperties": false
}),
}]
}

View File

@ -35,6 +35,7 @@ use super::jsonrpc::{
JSONRPC_VERSION, JSONRPC_VERSION,
}; };
use super::managed_temp::ManagedTempToolProvider; use super::managed_temp::ManagedTempToolProvider;
use super::plugins::PluginToolProvider;
use super::policy::ToolPolicyRegistry; use super::policy::ToolPolicyRegistry;
use super::templates::TemplateToolProvider; use super::templates::TemplateToolProvider;
use super::tickets::TicketToolProvider; use super::tickets::TicketToolProvider;
@ -84,6 +85,8 @@ pub struct McpServer {
/// Optional public managed-temp provider. The MCP surface says `managed_temp`; /// Optional public managed-temp provider. The MCP surface says `managed_temp`;
/// the provider maps those calls to application managed-temp use cases. /// the provider maps those calls to application managed-temp use cases.
managed_temp_tools: Option<Arc<dyn ManagedTempToolProvider>>, managed_temp_tools: Option<Arc<dyn ManagedTempToolProvider>>,
/// Optional public plugin administration provider.
plugin_tools: Option<Arc<dyn PluginToolProvider>>,
/// Optional per-requester MCP tool policy registry for constrained sessions. /// Optional per-requester MCP tool policy registry for constrained sessions.
tool_policies: Option<Arc<ToolPolicyRegistry>>, tool_policies: Option<Arc<ToolPolicyRegistry>>,
/// Optional durable MCP tool permission store. When wired, absence of project or /// Optional durable MCP tool permission store. When wired, absence of project or
@ -106,6 +109,7 @@ impl McpServer {
ticket_tools: None, ticket_tools: None,
template_tools: None, template_tools: None,
managed_temp_tools: None, managed_temp_tools: None,
plugin_tools: None,
tool_policies: None, tool_policies: None,
mcp_tool_permissions: None, mcp_tool_permissions: None,
} }
@ -156,6 +160,13 @@ impl McpServer {
self self
} }
/// Attaches the public plugin administration provider.
#[must_use]
pub fn with_plugin_tools(mut self, plugin_tools: Arc<dyn PluginToolProvider>) -> Self {
self.plugin_tools = Some(plugin_tools);
self
}
/// Attaches the requester-scoped MCP tool policy registry. /// Attaches the requester-scoped MCP tool policy registry.
#[must_use] #[must_use]
pub fn with_tool_policies(mut self, tool_policies: Arc<ToolPolicyRegistry>) -> Self { pub fn with_tool_policies(mut self, tool_policies: Arc<ToolPolicyRegistry>) -> Self {
@ -192,6 +203,7 @@ impl McpServer {
ticket_tools: self.ticket_tools.clone(), ticket_tools: self.ticket_tools.clone(),
template_tools: self.template_tools.clone(), template_tools: self.template_tools.clone(),
managed_temp_tools: self.managed_temp_tools.clone(), managed_temp_tools: self.managed_temp_tools.clone(),
plugin_tools: self.plugin_tools.clone(),
tool_policies: self.tool_policies.clone(), tool_policies: self.tool_policies.clone(),
mcp_tool_permissions: self.mcp_tool_permissions.clone(), mcp_tool_permissions: self.mcp_tool_permissions.clone(),
} }
@ -624,6 +636,32 @@ impl McpServer {
}; };
} }
if tools::is_plugin_tool(&name) {
let result = match &self.plugin_tools {
Some(provider) => {
provider
.handle_plugin_tool(&self.project, &self.requester, &name, arguments)
.await
}
None => Err(super::plugins::PluginToolError::new(
"UNAVAILABLE",
"plugin tools are not configured",
)),
};
self.publish_processed(&name, result.is_ok());
return match result {
Ok(value) => {
let text = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_owned());
Ok(tool_result_text(&text, false))
}
Err(err) => {
let text =
serde_json::to_string(&err.to_value()).unwrap_or_else(|_| err.to_string());
Ok(tool_result_text(&text, true))
}
};
}
// The handshake-provided requester is still passed to the mapper for tools that // The handshake-provided requester is still passed to the mapper for tools that
// need peer identity. // need peer identity.
let command = match tools::map_tool_call(&name, &arguments, &self.requester) { let command = match tools::map_tool_call(&name, &arguments, &self.requester) {

View File

@ -96,6 +96,7 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
"idea_managed_temp_allocate", "idea_managed_temp_allocate",
"idea_managed_temp_release", "idea_managed_temp_release",
"idea_managed_temp_gc", "idea_managed_temp_gc",
"idea_plugin_reload",
]; ];
/// All MCP tool names that have an explicit access classification. /// All MCP tool names that have an explicit access classification.
@ -157,6 +158,7 @@ pub fn tool_returns_reply(tool: &str) -> bool {
) || is_ticket_tool(tool) ) || is_ticket_tool(tool)
|| is_template_tool(tool) || is_template_tool(tool)
|| is_managed_temp_tool(tool) || is_managed_temp_tool(tool)
|| is_plugin_tool(tool)
} }
/// Whether `tool` is a public ticket MCP tool. /// Whether `tool` is a public ticket MCP tool.
@ -177,6 +179,12 @@ pub fn is_managed_temp_tool(tool: &str) -> bool {
super::managed_temp::is_managed_temp_tool(tool) super::managed_temp::is_managed_temp_tool(tool)
} }
/// Whether `tool` is a public plugin MCP tool.
#[must_use]
pub fn is_plugin_tool(tool: &str) -> bool {
super::plugins::is_plugin_tool(tool)
}
/// The full catalogue advertised on `tools/list`. /// The full catalogue advertised on `tools/list`.
/// ///
/// Exactly the tools whose mapping target already exists as an /// Exactly the tools whose mapping target already exists as an
@ -499,6 +507,7 @@ pub fn catalogue() -> Vec<ToolDef> {
tools.extend(super::tickets::catalogue()); tools.extend(super::tickets::catalogue());
tools.extend(super::templates::catalogue()); tools.extend(super::templates::catalogue());
tools.extend(super::managed_temp::catalogue()); tools.extend(super::managed_temp::catalogue());
tools.extend(super::plugins::catalogue());
tools tools
} }

View File

@ -59,7 +59,8 @@ use application::{
use infrastructure::orchestrator::mcp::jsonrpc::error_codes; use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
use infrastructure::orchestrator::mcp::tools::classified_tool_names; use infrastructure::orchestrator::mcp::tools::classified_tool_names;
use infrastructure::orchestrator::mcp::{ use infrastructure::orchestrator::mcp::{
TemplateToolError, TemplateToolProvider, TicketToolError, TicketToolProvider, PluginToolError, PluginToolProvider, TemplateToolError, TemplateToolProvider, TicketToolError,
TicketToolProvider,
}; };
use infrastructure::{ use infrastructure::{
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport, InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
@ -631,6 +632,31 @@ struct FakeTemplateTools {
mutation_attempts: Arc<Mutex<usize>>, mutation_attempts: Arc<Mutex<usize>>,
} }
#[derive(Clone, Default)]
struct FakePluginTools {
calls: Arc<Mutex<Vec<(String, String, Value)>>>,
}
#[async_trait]
impl PluginToolProvider for FakePluginTools {
async fn handle_plugin_tool(
&self,
_project: &Project,
requester: &str,
name: &str,
arguments: Value,
) -> Result<Value, PluginToolError> {
self.calls
.lock()
.unwrap()
.push((requester.to_owned(), name.to_owned(), arguments.clone()));
Ok(json!({
"plugin": { "id": arguments["pluginId"] },
"changed": true
}))
}
}
impl FakeTemplateTools { impl FakeTemplateTools {
fn calls(&self) -> Vec<String> { fn calls(&self) -> Vec<String> {
self.calls.lock().unwrap().clone() self.calls.lock().unwrap().clone()
@ -774,6 +800,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
"idea_template_create", "idea_template_create",
"idea_template_update", "idea_template_update",
"idea_template_delete", "idea_template_delete",
// Public plugin administration tools.
"idea_plugin_reload",
] { ] {
assert!( assert!(
names.contains(&expected), names.contains(&expected),
@ -783,8 +811,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() {
assert!(!names.contains(&"idea_reply")); assert!(!names.contains(&"idea_reply"));
assert_eq!( assert_eq!(
tools.len(), tools.len(),
47, 48,
"exactly the forty-seven exposed idea_* tools; got {names:?}" "exactly the forty-eight exposed idea_* tools; got {names:?}"
); );
// Every tool advertises an object input schema. // Every tool advertises an object input schema.
@ -897,6 +925,7 @@ async fn requester_with_durable_store_but_no_override_sees_read_only_tools() {
assert!(!names.contains(&"idea_template_create")); assert!(!names.contains(&"idea_template_create"));
assert!(!names.contains(&"idea_template_update")); assert!(!names.contains(&"idea_template_update"));
assert!(!names.contains(&"idea_template_delete")); assert!(!names.contains(&"idea_template_delete"));
assert!(!names.contains(&"idea_plugin_reload"));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -1006,6 +1035,43 @@ async fn general_agent_without_mcp_override_is_read_only_for_tools_call() {
assert_eq!(template_tools.mutation_attempts(), 0); assert_eq!(template_tools.mutation_attempts(), 0);
} }
#[tokio::test]
async fn explicitly_allowed_plugin_reload_routes_to_provider_with_camel_case_arguments() {
let (service, _s) = build_service(FakeContexts::new());
let plugin_tools = Arc::new(FakePluginTools::default());
let agent = AgentId::from_uuid(Uuid::from_u128(271));
let server = server_with_mcp_permissions(service, allow_doc(agent, &["idea_plugin_reload"]))
.with_plugin_tools(plugin_tools.clone())
.for_requester(agent.to_string());
let response = server
.handle_raw(&tools_call(
271,
"idea_plugin_reload",
json!({
"pluginId": "dev.acme.gitgraph",
"expectedContentHash": "abc123"
}),
))
.await
.expect("reply owed");
assert!(
response.error.is_none(),
"transport error: {:?}",
response.error
);
let result = response.result.expect("tool result");
assert_eq!(result["isError"], json!(false), "got {result}");
let payload: Value = serde_json::from_str(result_text(&result)).unwrap();
assert_eq!(payload["plugin"]["id"], json!("dev.acme.gitgraph"));
let calls = plugin_tools.calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, agent.to_string());
assert_eq!(calls[0].1, "idea_plugin_reload");
assert_eq!(calls[0].2["expectedContentHash"], json!("abc123"));
}
#[tokio::test] #[tokio::test]
async fn legacy_mcp_requester_with_durable_store_is_read_only() { async fn legacy_mcp_requester_with_durable_store_is_read_only() {
let (service, _mailbox, _sessions) = build_service_with_mailbox(FakeContexts::new()); let (service, _mailbox, _sessions) = build_service_with_mailbox(FakeContexts::new());