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:
@ -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,
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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(®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(
|
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()));
|
||||||
|
|||||||
@ -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")]
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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};
|
||||||
|
|||||||
71
crates/infrastructure/src/orchestrator/mcp/plugins.rs
Normal file
71
crates/infrastructure/src/orchestrator/mcp/plugins.rs
Normal 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
|
||||||
|
}),
|
||||||
|
}]
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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());
|
||||||
|
|||||||
Reference in New Issue
Block a user