From 805b1b4d0d23d1d2b7df970e02c7bebd27bf2e57 Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 7 Sep 2026 19:29:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(plugins):=20hot=20reload=20des=20plugins?= =?UTF-8?q?=20sans=20relancer=20IdeA=20=E2=80=94=20use=20case=20ReloadPlug?= =?UTF-8?q?in=20(invalidation=20registry/catalogue,=20resultat=20hashes+ch?= =?UTF-8?q?anged),=20tools=20MCP=20d'administration=20plugin=20agent-facin?= =?UTF-8?q?g=20(mcp/plugins.rs=20+=20PluginToolProvider),=20evenement=20do?= =?UTF-8?q?maine=20plugin=5Freloaded=20relaye=20sur=20le=20wire=20(dto/eve?= =?UTF-8?q?nts),=20exposition=20openai=5Ftools=20rafraichie,=20wiring=20ap?= =?UTF-8?q?p-tauri=20=E2=80=94=20#271/#269=20(QA=20verte=20#273:=20applica?= =?UTF-8?q?tion=2032,=20mcp=5Fserver=2038,=20openai=5Ftools=2010,=20plugin?= =?UTF-8?q?=5Finstall=5Fload=205,=20plugin=2010,=20wire=201=20=E2=80=94=20?= =?UTF-8?q?96=20tests=20cibl=C3=A9s;=20app-tauri=20compile=20sans=20deskto?= =?UTF-8?q?p=20runtime)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/app-tauri/src/lib.rs | 1 + crates/app-tauri/src/plugins.rs | 19 +- crates/application/src/lib.rs | 23 +- crates/application/src/plugin/mod.rs | 359 +++++++++++++++++- crates/backend/src/dto.rs | 53 +++ crates/backend/src/events.rs | 41 ++ crates/backend/src/lib.rs | 95 ++++- crates/backend/src/openai_tools.rs | 101 +++++ crates/domain/src/events.rs | 11 + .../src/orchestrator/mcp/mod.rs | 2 + .../src/orchestrator/mcp/plugins.rs | 71 ++++ .../src/orchestrator/mcp/server.rs | 38 ++ .../src/orchestrator/mcp/tools.rs | 9 + crates/infrastructure/tests/mcp_server.rs | 72 +++- 14 files changed, 863 insertions(+), 32 deletions(-) create mode 100644 crates/infrastructure/src/orchestrator/mcp/plugins.rs diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 5c5e02f1..2ab695d7 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -410,6 +410,7 @@ pub fn run() { plugins::plugin_review_package, plugins::plugin_install_from_archive, plugins::plugin_install_from_directory, + plugins::plugin_reload, plugins::plugin_set_enabled, plugins::plugin_uninstall, plugins::plugin_list_runtime_contributions, diff --git a/crates/app-tauri/src/plugins.rs b/crates/app-tauri/src/plugins.rs index bb37a7b6..43d509ed 100644 --- a/crates/app-tauri/src/plugins.rs +++ b/crates/app-tauri/src/plugins.rs @@ -9,12 +9,13 @@ use backend::dto::{ PluginConfigDocumentUpdateDto, PluginConfigDocumentWriteResultDto, PluginEventBatchDto, PluginEventPollDto, PluginEventSubscribeDto, PluginEventSubscriptionDto, PluginEventUnsubscribeDto, PluginInstallResultDto, PluginProjectStructureDto, - PluginProjectStructureQueryDto, PluginReviewDto, PluginRunCommandDto, + PluginProjectStructureQueryDto, PluginReloadResultDto, PluginReviewDto, PluginRunCommandDto, PluginRuntimeContributionCatalogDto, PluginStorageGetDto, PluginStorageSetDto, PluginTaskDto, PluginTaskStatusDto, PluginToolchainDiagnosticDto, PluginToolchainDiagnosticRequestDto, PluginUninstallResultDto, PluginWorkspaceBinaryFileDto, PluginWorkspaceDirectoryListingDto, PluginWorkspacePathDto, PluginWorkspaceStatDto, PluginWorkspaceTextFileDto, - PluginWorkspaceWriteBinaryDto, PluginWorkspaceWriteTextDto, ReviewPluginPackageDto, + PluginWorkspaceWriteBinaryDto, PluginWorkspaceWriteTextDto, ReloadPluginDto, + ReviewPluginPackageDto, }; use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore}; use domain::{PluginId, RelativePath}; @@ -122,6 +123,20 @@ pub async fn plugin_install_from_directory( result } +/// Hot-reloads a plugin from its recorded directory source. +#[tauri::command] +pub async fn plugin_reload( + input: ReloadPluginDto, + state: State<'_, AppState>, +) -> Result { + state + .reload_plugin + .execute(input.into()) + .await + .map(PluginReloadResultDto::from) + .map_err(ErrorDto::from) +} + /// Enables or disables a plugin. #[tauri::command] pub async fn plugin_set_enabled( diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 5bd0436a..7644bd78 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -182,17 +182,18 @@ pub use plugin::{ PluginDiagnosticMessage, PluginEnvDiagnostic, PluginEnvRequirement, PluginEventBatch, PluginEventPollInput, PluginEventSubscribeInput, PluginEventSubscription, PluginEventSubscriptions, PluginEventUnsubscribeInput, PluginFileDiagnostic, - PluginFileRequirement, PluginInstallResult, PluginPublicEvent, PluginReview, - PluginRunCommandInput, PluginRuntimeCatalog, PluginRuntimePlugin, PluginStorageAccess, - PluginStorageGetInput, PluginStorageSetInput, PluginTaskStatusInput, PluginToolDiagnostic, - PluginToolRequirement, PluginToolchainDiagnostic, PluginToolchainDiagnosticInput, - PluginToolchainDiagnostics, PluginWorkspaceAccess, PluginWorkspaceBinaryFile, - PluginWorkspaceDirEntry, PluginWorkspaceDirectoryListing, PluginWorkspacePathInput, - PluginWorkspaceStat, PluginWorkspaceTextFile, PluginWorkspaceWriteBinaryInput, - PluginWorkspaceWriteTextInput, ProjectConvention, ProjectModule, ProjectStructureEntry, - ProjectStructureQuery, QueryProjectStructure, QueryProjectStructureInput, - ReconcilePluginMcpServers, ReviewPluginPackage, ReviewPluginPackageInput, SetPluginEnabled, - SetPluginEnabledInput, UninstallPlugin, UninstallPluginInput, UninstallPluginResult, + PluginFileRequirement, PluginInstallResult, PluginPublicEvent, PluginReloadResult, + PluginReview, PluginRunCommandInput, PluginRuntimeCatalog, PluginRuntimePlugin, + PluginStorageAccess, PluginStorageGetInput, PluginStorageSetInput, PluginTaskStatusInput, + PluginToolDiagnostic, PluginToolRequirement, PluginToolchainDiagnostic, + PluginToolchainDiagnosticInput, PluginToolchainDiagnostics, PluginWorkspaceAccess, + PluginWorkspaceBinaryFile, PluginWorkspaceDirEntry, PluginWorkspaceDirectoryListing, + PluginWorkspacePathInput, PluginWorkspaceStat, PluginWorkspaceTextFile, + PluginWorkspaceWriteBinaryInput, PluginWorkspaceWriteTextInput, ProjectConvention, + ProjectModule, ProjectStructureEntry, ProjectStructureQuery, QueryProjectStructure, + QueryProjectStructureInput, ReconcilePluginMcpServers, ReloadPlugin, ReloadPluginInput, + ReviewPluginPackage, ReviewPluginPackageInput, SetPluginEnabled, SetPluginEnabledInput, + UninstallPlugin, UninstallPluginInput, UninstallPluginResult, }; pub use project::{ CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject, diff --git a/crates/application/src/plugin/mod.rs b/crates/application/src/plugin/mod.rs index 97642e85..a20de204 100644 --- a/crates/application/src/plugin/mod.rs +++ b/crates/application/src/plugin/mod.rs @@ -117,6 +117,36 @@ pub struct PluginInstallResult { 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, +} + +/// 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, + /// MCP server state after reconciliation. + pub mcp_statuses: domain::PluginMcpStatusSet, + /// Non-fatal post-commit warnings. + pub warnings: Vec, +} + /// Uninstall result. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -2396,6 +2426,171 @@ impl InstallPluginFromDirectory { } } +/// Reloads an installed directory plugin without restarting IdeA. +pub struct ReloadPlugin { + packages: Arc, + registry: Arc, + validator: Arc, + events: Arc, + mcp: Arc, +} + +impl ReloadPlugin { + /// Builds the use case. + #[must_use] + pub fn new( + packages: Arc, + registry: Arc, + validator: Arc, + events: Arc, + mcp: Arc, + ) -> 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 { + 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(®istry) + .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(), + ®istry, + ) + .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( packages: &dyn PluginPackageStore, registry_store: &dyn PluginRegistryStore, @@ -3497,6 +3692,14 @@ mod tests { } fn with_manifest_and_staged_count(bytes: Vec, staged_count: usize) -> Self { + Self::with_manifest_staged_count_and_hash(bytes, staged_count, "abc123") + } + + fn with_manifest_staged_count_and_hash( + bytes: Vec, + staged_count: usize, + staged_hash: &str, + ) -> Self { let mut manifests = HashMap::new(); manifests.insert("dev.acme.gitgraph".to_owned(), bytes); let staged = (0..staged_count) @@ -3505,7 +3708,7 @@ mod tests { source: PluginInstallSource::Directory { path_label: "/source/plugin".to_owned(), }, - content_hash: content_hash("abc123"), + content_hash: content_hash(staged_hash), }) .collect(); Self { @@ -3971,6 +4174,160 @@ mod tests { 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] async fn reconcile_mcp_does_not_spawn_pending_uninstall_plugin_servers() { let packages = Arc::new(FakePackages::with_manifest(valid_manifest())); diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 47d3dbe7..492b78ca 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -199,6 +199,59 @@ impl From 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, +} + +impl From 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, + /// MCP status after reconciliation. + pub mcp_statuses: domain::PluginMcpStatusSet, + /// Non-fatal warnings. + pub warnings: Vec, +} + +impl From 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. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/backend/src/events.rs b/crates/backend/src/events.rs index 8d645e1f..9d6734e3 100644 --- a/crates/backend/src/events.rs +++ b/crates/backend/src/events.rs @@ -127,6 +127,18 @@ pub enum DomainEventDto { /// Installed version. 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. #[serde(rename_all = "camelCase")] PluginEnabled { @@ -782,6 +794,17 @@ impl From<&DomainEvent> for DomainEventDto { plugin_id: plugin_id.to_string(), 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 { plugin_id: plugin_id.to_string(), }, @@ -1327,6 +1350,24 @@ mod tests { 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] fn model_server_status_changed_relays_downloading_to_dto_and_wire() { let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged { diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 2ff44fc5..eb25af96 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -44,21 +44,22 @@ use application::{ ReadIssue, ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn, - RecordTurnProvider, ReferenceProfiles, ReleaseManagedTemp, RenameDevice, RenameLayout, - RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentCapabilities, - ResolveAgentPermissions, ResolveAgentSystemPermissions, ResolveManagedTemp, ResolveMemoryLinks, - RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage, RevokeAllDevices, RevokeDevice, - RotateConversationLog, SaveChatDraft, SaveEmbedderProfile, SaveModelServer, - SaveOpenCodeProviderProfile, SaveProfile, SessionLimitService, SetActiveLayout, - SetPluginEnabled, SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents, - SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions, - SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice, - UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, - UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, - UpdateAgentPermissions, UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, - UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectMcpToolPermissions, - UpdateProjectPermissions, UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, - WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, + RecordTurnProvider, ReferenceProfiles, ReleaseManagedTemp, ReloadPlugin, ReloadPluginInput, + RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, + ResolveAgentCapabilities, ResolveAgentPermissions, ResolveAgentSystemPermissions, + ResolveManagedTemp, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, + ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft, + SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile, + SessionLimitService, SetActiveLayout, SetPluginEnabled, SlashCommandRegistry, + SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, + StructuredRoutingMode, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, + TerminalSessions, TouchDevice, UnassignSkillFromAgent, UnassignTicketFromSprint, + UninstallPlugin, UnlinkIssues, UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort, + UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions, + UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext, + UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions, + UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, + AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; 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::openai_tools::{AppOpenAiToolInvoker, LateBoundOpenAiToolInvoker}; +use infrastructure::orchestrator::mcp::{PluginToolError, PluginToolProvider}; use infrastructure::StdioTransport; use interprocess::local_socket::tokio::Listener as LocalSocketListener; @@ -143,6 +145,51 @@ fn spawn_detached(future: impl std::future::Future + Send + 'static }); } +struct AppPluginToolProvider { + reload: Arc, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PluginReloadToolArgs { + plugin_id: String, + expected_content_hash: Option, +} + +#[async_trait] +impl PluginToolProvider for AppPluginToolProvider { + async fn handle_plugin_tool( + &self, + _project: &Project, + _requester: &str, + name: &str, + arguments: Value, + ) -> Result { + 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)] mod backend_core_build_tests { use super::BackendCore; @@ -1224,6 +1271,8 @@ pub struct BackendCore { pub(crate) template_tool_provider: Arc, /// MCP provider for the public `idea_managed_temp_*` tools. pub(crate) managed_temp_tool_provider: Arc, + /// MCP provider for public plugin administration tools. + pub(crate) plugin_tool_provider: Arc, /// Open an ephemeral AI assistant chat bound to one ticket. pub open_ticket_assistant: Arc, /// Close an ephemeral AI assistant chat bound to one ticket. @@ -1324,6 +1373,8 @@ pub struct BackendCore { pub install_plugin_from_archive: Arc, /// Install a plugin directory snapshot. pub install_plugin_from_directory: Arc, + /// Hot-reload an installed plugin from its recorded directory source. + pub reload_plugin: Arc, /// List installed plugins for admin UI. pub list_plugins: Arc, /// Enable or disable a plugin. @@ -2704,6 +2755,16 @@ impl BackendCore { Arc::clone(&events_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 = Arc::new(AppPluginToolProvider { + reload: Arc::clone(&reload_plugin), + }); let set_plugin_enabled = Arc::new(SetPluginEnabled::new( Arc::clone(&plugin_package_store), Arc::clone(&plugin_registry_store), @@ -3147,6 +3208,7 @@ impl BackendCore { Arc::clone(&ticket_tool_provider), Arc::clone(&template_tool_provider), Arc::clone(&managed_temp_tool_provider), + Arc::clone(&plugin_tool_provider), )) as Arc); let stop_live_agent = Arc::new( @@ -3364,6 +3426,7 @@ impl BackendCore { review_plugin_package, install_plugin_from_archive, install_plugin_from_directory, + reload_plugin, list_plugins, set_plugin_enabled, uninstall_plugin, @@ -3382,6 +3445,7 @@ impl BackendCore { ticket_tool_binder, template_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_template_tools(Arc::clone(&self.template_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_mcp_tool_permissions(Arc::clone(&self.mcp_tool_permission_store)), endpoint, diff --git a/crates/backend/src/openai_tools.rs b/crates/backend/src/openai_tools.rs index 6b83ddcd..865e2d2d 100644 --- a/crates/backend/src/openai_tools.rs +++ b/crates/backend/src/openai_tools.rs @@ -14,6 +14,7 @@ use domain::ports::{ ToolSpec, }; use domain::{AgentToolPolicy, IssueRef, McpToolPolicy, Project}; +use infrastructure::orchestrator::mcp::PluginToolProvider; use infrastructure::{ManagedTempToolProvider, TemplateToolProvider, TicketToolProvider}; use serde_json::Value; @@ -29,6 +30,7 @@ pub struct AppOpenAiToolInvoker { ticket_tools: Arc, template_tools: Arc, managed_temp_tools: Arc, + plugin_tools: Arc, } /// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la @@ -103,6 +105,7 @@ impl AppOpenAiToolInvoker { ticket_tools: Arc, template_tools: Arc, managed_temp_tools: Arc, + plugin_tools: Arc, ) -> Self { Self { orchestrator, @@ -112,6 +115,7 @@ impl AppOpenAiToolInvoker { ticket_tools, template_tools, managed_temp_tools, + plugin_tools, } } } @@ -232,6 +236,19 @@ impl ToolInvoker for AppOpenAiToolInvoker { 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) .map_err(|e| match e { infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => { @@ -424,6 +441,7 @@ mod tests { AgentId, AgentMcpToolPolicyOverride, McpToolPolicy, Project, ProjectId, ProjectMcpToolPermissions, ProjectPath, RemoteRef, StoreError, Workspace, }; + use infrastructure::orchestrator::mcp::PluginToolError; use infrastructure::{ManagedTempToolError, TemplateToolError, TicketToolError}; use serde_json::json; use uuid::Uuid; @@ -606,6 +624,36 @@ mod tests { Arc::new(FakeManagedTempTools::default()) } + #[derive(Default)] + struct FakePluginTools { + calls: Mutex>, + } + + #[async_trait] + impl PluginToolProvider for FakePluginTools { + async fn handle_plugin_tool( + &self, + _project: &Project, + requester: &str, + name: &str, + arguments: Value, + ) -> Result { + 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 { + Arc::new(FakePluginTools::default()) + } + fn issue_ref(raw: &str) -> IssueRef { IssueRef::from_str(raw).unwrap() } @@ -772,6 +820,7 @@ mod tests { ticket_tools.clone(), template_tools, fake_managed_temp_tools(), + fake_plugin_tools(), ); let denied = invoker @@ -838,6 +887,7 @@ mod tests { ticket_tools.clone(), template_tools.clone(), fake_managed_temp_tools(), + fake_plugin_tools(), ); for tool in ["idea_ticket_list", "idea_template_list"] { @@ -928,6 +978,7 @@ mod tests { Arc::new(FakeTicketTools::default()), Arc::new(FakeTemplateTools::default()), fake_managed_temp_tools(), + fake_plugin_tools(), ); let tools = invoker @@ -968,6 +1019,7 @@ mod tests { Arc::new(FakeTicketTools::default()), Arc::new(FakeTemplateTools::default()), fake_managed_temp_tools(), + fake_plugin_tools(), ); let tools = invoker @@ -1001,6 +1053,7 @@ mod tests { ticket_tools.clone(), template_tools, fake_managed_temp_tools(), + fake_plugin_tools(), ); let result = invoker @@ -1048,6 +1101,7 @@ mod tests { ticket_tools, template_tools.clone(), fake_managed_temp_tools(), + fake_plugin_tools(), ); let result = invoker @@ -1095,4 +1149,51 @@ mod tests { 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, + 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); + } } diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 3e3a5f1e..1ec11984 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -61,6 +61,17 @@ pub enum DomainEvent { /// Installed version. 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. PluginEnabled { /// Plugin id. diff --git a/crates/infrastructure/src/orchestrator/mcp/mod.rs b/crates/infrastructure/src/orchestrator/mcp/mod.rs index 6d778273..d88e25a3 100644 --- a/crates/infrastructure/src/orchestrator/mcp/mod.rs +++ b/crates/infrastructure/src/orchestrator/mcp/mod.rs @@ -30,6 +30,7 @@ pub mod jsonrpc; pub mod managed_temp; +pub mod plugins; pub mod policy; pub mod server; pub mod templates; @@ -41,6 +42,7 @@ pub use jsonrpc::{ JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION, }; pub use managed_temp::{ManagedTempToolError, ManagedTempToolProvider}; +pub use plugins::{PluginToolError, PluginToolProvider}; pub use policy::ToolPolicyRegistry; pub use server::McpServer; pub use templates::{TemplateToolError, TemplateToolProvider}; diff --git a/crates/infrastructure/src/orchestrator/mcp/plugins.rs b/crates/infrastructure/src/orchestrator/mcp/plugins.rs new file mode 100644 index 00000000..7f84e6a5 --- /dev/null +++ b/crates/infrastructure/src/orchestrator/mcp/plugins.rs @@ -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) -> 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; +} + +/// 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 { + 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 + }), + }] +} diff --git a/crates/infrastructure/src/orchestrator/mcp/server.rs b/crates/infrastructure/src/orchestrator/mcp/server.rs index 8ba9424e..befb0250 100644 --- a/crates/infrastructure/src/orchestrator/mcp/server.rs +++ b/crates/infrastructure/src/orchestrator/mcp/server.rs @@ -35,6 +35,7 @@ use super::jsonrpc::{ JSONRPC_VERSION, }; use super::managed_temp::ManagedTempToolProvider; +use super::plugins::PluginToolProvider; use super::policy::ToolPolicyRegistry; use super::templates::TemplateToolProvider; use super::tickets::TicketToolProvider; @@ -84,6 +85,8 @@ pub struct McpServer { /// Optional public managed-temp provider. The MCP surface says `managed_temp`; /// the provider maps those calls to application managed-temp use cases. managed_temp_tools: Option>, + /// Optional public plugin administration provider. + plugin_tools: Option>, /// Optional per-requester MCP tool policy registry for constrained sessions. tool_policies: Option>, /// Optional durable MCP tool permission store. When wired, absence of project or @@ -106,6 +109,7 @@ impl McpServer { ticket_tools: None, template_tools: None, managed_temp_tools: None, + plugin_tools: None, tool_policies: None, mcp_tool_permissions: None, } @@ -156,6 +160,13 @@ impl McpServer { self } + /// Attaches the public plugin administration provider. + #[must_use] + pub fn with_plugin_tools(mut self, plugin_tools: Arc) -> Self { + self.plugin_tools = Some(plugin_tools); + self + } + /// Attaches the requester-scoped MCP tool policy registry. #[must_use] pub fn with_tool_policies(mut self, tool_policies: Arc) -> Self { @@ -192,6 +203,7 @@ impl McpServer { ticket_tools: self.ticket_tools.clone(), template_tools: self.template_tools.clone(), managed_temp_tools: self.managed_temp_tools.clone(), + plugin_tools: self.plugin_tools.clone(), tool_policies: self.tool_policies.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 // need peer identity. let command = match tools::map_tool_call(&name, &arguments, &self.requester) { diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs index a0f37d55..fed3149c 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tools.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -96,6 +96,7 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[ "idea_managed_temp_allocate", "idea_managed_temp_release", "idea_managed_temp_gc", + "idea_plugin_reload", ]; /// 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_template_tool(tool) || is_managed_temp_tool(tool) + || is_plugin_tool(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) } +/// 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`. /// /// Exactly the tools whose mapping target already exists as an @@ -499,6 +507,7 @@ pub fn catalogue() -> Vec { tools.extend(super::tickets::catalogue()); tools.extend(super::templates::catalogue()); tools.extend(super::managed_temp::catalogue()); + tools.extend(super::plugins::catalogue()); tools } diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index aa30ee3b..6a9a29f2 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -59,7 +59,8 @@ use application::{ use infrastructure::orchestrator::mcp::jsonrpc::error_codes; use infrastructure::orchestrator::mcp::tools::classified_tool_names; use infrastructure::orchestrator::mcp::{ - TemplateToolError, TemplateToolProvider, TicketToolError, TicketToolProvider, + PluginToolError, PluginToolProvider, TemplateToolError, TemplateToolProvider, TicketToolError, + TicketToolProvider, }; use infrastructure::{ InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport, @@ -631,6 +632,31 @@ struct FakeTemplateTools { mutation_attempts: Arc>, } +#[derive(Clone, Default)] +struct FakePluginTools { + calls: Arc>>, +} + +#[async_trait] +impl PluginToolProvider for FakePluginTools { + async fn handle_plugin_tool( + &self, + _project: &Project, + requester: &str, + name: &str, + arguments: Value, + ) -> Result { + self.calls + .lock() + .unwrap() + .push((requester.to_owned(), name.to_owned(), arguments.clone())); + Ok(json!({ + "plugin": { "id": arguments["pluginId"] }, + "changed": true + })) + } +} + impl FakeTemplateTools { fn calls(&self) -> Vec { self.calls.lock().unwrap().clone() @@ -774,6 +800,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() { "idea_template_create", "idea_template_update", "idea_template_delete", + // Public plugin administration tools. + "idea_plugin_reload", ] { assert!( names.contains(&expected), @@ -783,8 +811,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() { assert!(!names.contains(&"idea_reply")); assert_eq!( tools.len(), - 47, - "exactly the forty-seven exposed idea_* tools; got {names:?}" + 48, + "exactly the forty-eight exposed idea_* tools; got {names:?}" ); // 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_update")); 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); } +#[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] async fn legacy_mcp_requester_with_durable_store_is_read_only() { let (service, _mailbox, _sessions) = build_service_with_mailbox(FakeContexts::new());