merge(plugins): les serveurs MCP des plugins assignés atteignent la session de l'agent — #302 (QA verte reconfirmée par Git: rustup run stable cargo fmt --all -- --check, git diff --check, cargo test -p application --lib 180/180, cargo test -p application --test agent_lifecycle 111/111, cargo test -p domain plugin --lib 7/7, cargo check -p backend OK, cargo check -p app-tauri OK; branche fix/302-plugin-mcp-tools-not-visible commit 78b379a; cause racine: assignments.tools PluginToolRef persistés mais jamais consommés par le launcher, seules les configs de session ne contenaient que le serveur MCP natif idea donc tool_search ne voyait pas les tools plugin; correctif borné 4 fichiers: domain/plugin.rs nom client stable idea_plugin__<enc>__<enc>, application/project_plugin.rs projection assigned_mcp_servers conditionnée actif/installé/autoStart, application/agent/lifecycle.rs matérialisation+réconciliation Claude .mcp.json / Codex config.toml / OpenCode opencode.json + racines Landlock lecture, backend/lib.rs branchement with_plugin_mcp_resolver; restants: gap OpenAI/direct tools_for_context (client JSON-RPC dédié) à traiter séparément, validation live UnitySOundTool après rebuild AppImage; fichiers tickets/296/*, tickets/299-302/*, counter/index, attachments/ et scripts/ hors périmètre; aucun push)
This commit is contained in:
@ -30,8 +30,8 @@ use domain::{
|
||||
ContextInjection, ConversationId, ConversationParty, DomainEvent, EffectivePermissions,
|
||||
Handoff, HandoffStore, ManagedTempKind, ManifestEntry, MarkdownDoc, MemoryIndexEntry,
|
||||
MemoryType, NetworkPolicy, NodeId, OrchestrationCapabilitySnapshot, PermissionProjector,
|
||||
ProfileId, Project, ProjectPath, ProjectedFile, ProjectionContext, ProjectorKey,
|
||||
ProviderSessionStore, PtySize, SessionId, SessionKind, SessionStatus, SkillKind,
|
||||
PluginMcpServerSpec, ProfileId, Project, ProjectPath, ProjectedFile, ProjectionContext,
|
||||
ProjectorKey, ProviderSessionStore, PtySize, SessionId, SessionKind, SessionStatus, SkillKind,
|
||||
TerminalSession, HANDOFF_SUMMARY_MAX_CHARS,
|
||||
};
|
||||
|
||||
@ -50,6 +50,7 @@ use crate::model_server::{
|
||||
EnsureLocalModelServer, EnsureLocalModelServerInput, ModelServerUseGuard,
|
||||
};
|
||||
use crate::project::project_context_path;
|
||||
use crate::project_plugin::ProjectPluginResolver;
|
||||
use crate::skill::{AgentCapability, AssignedSkillResolver, ResolveAgentCapabilities};
|
||||
use crate::terminal::{
|
||||
stop_live_agent_runtime_in_project, CloseTerminal, LiveSessions, StructuredSessions,
|
||||
@ -1539,6 +1540,31 @@ fn enrich_sandbox_plan_with_managed_temp_roots(
|
||||
enrich_sandbox_plan_with_rw_roots(plan, managed_temp_roots)
|
||||
}
|
||||
|
||||
fn enrich_sandbox_plan_with_plugin_mcp_roots(
|
||||
plan: Option<SandboxPlan>,
|
||||
servers: &[PluginMcpServerSpec],
|
||||
) -> Option<SandboxPlan> {
|
||||
let mut plan = plan?;
|
||||
let roots = servers.iter().flat_map(|server| {
|
||||
let command_parent = server
|
||||
.command
|
||||
.rsplit_once(['/', '\\'])
|
||||
.map(|(parent, _)| parent.to_owned());
|
||||
std::iter::once(server.cwd.clone()).chain(command_parent)
|
||||
});
|
||||
for root in roots.filter(|root| !root.is_empty()) {
|
||||
match plan.allowed.iter_mut().find(|grant| grant.abs_root == root) {
|
||||
Some(grant) => grant.access.insert(PathAccess::RO),
|
||||
None => plan.allowed.push(PathGrant {
|
||||
abs_root: root,
|
||||
access: PathAccess::RO,
|
||||
}),
|
||||
}
|
||||
}
|
||||
plan.allowed.sort_by(|a, b| a.abs_root.cmp(&b.abs_root));
|
||||
Some(plan)
|
||||
}
|
||||
|
||||
fn enrich_sandbox_plan_with_rw_roots(
|
||||
plan: Option<SandboxPlan>,
|
||||
writable_roots: &[String],
|
||||
@ -1738,6 +1764,10 @@ pub struct LaunchAgent {
|
||||
/// Releases the primary launch-scoped managed temp directory when launch fails
|
||||
/// before registration.
|
||||
managed_temp_releaser: Option<Arc<ReleaseManagedTemp>>,
|
||||
/// Resolves agent-assigned plugin MCP servers into launch-ready CLI wiring.
|
||||
/// Optional for legacy tests/callers; runtime composition wires the shared
|
||||
/// project-plugin resolver used by the capability assignment use cases.
|
||||
plugin_mcp: Option<Arc<ProjectPluginResolver>>,
|
||||
/// Explicit intent for structured profiles when structured ports are absent.
|
||||
structured_routing_mode: StructuredRoutingMode,
|
||||
}
|
||||
@ -1789,6 +1819,7 @@ impl LaunchAgent {
|
||||
managed_temp_resolver: None,
|
||||
managed_temp_allocator: None,
|
||||
managed_temp_releaser: None,
|
||||
plugin_mcp: None,
|
||||
structured_routing_mode: StructuredRoutingMode::HumanPtyFallback,
|
||||
}
|
||||
}
|
||||
@ -1811,6 +1842,14 @@ impl LaunchAgent {
|
||||
self
|
||||
}
|
||||
|
||||
/// Injects the resolver that projects assigned plugin MCP servers into the
|
||||
/// launched CLI's isolated MCP configuration.
|
||||
#[must_use]
|
||||
pub fn with_plugin_mcp_resolver(mut self, resolver: Arc<ProjectPluginResolver>) -> Self {
|
||||
self.plugin_mcp = Some(resolver);
|
||||
self
|
||||
}
|
||||
|
||||
/// Injects the local model-server ensure use case used by OpenCode profiles.
|
||||
#[must_use]
|
||||
pub fn with_local_model_server(mut self, ensure: Arc<EnsureLocalModelServer>) -> Self {
|
||||
@ -2484,6 +2523,15 @@ impl LaunchAgent {
|
||||
}
|
||||
};
|
||||
|
||||
let plugin_mcp_servers = match &self.plugin_mcp {
|
||||
Some(resolver) if profile.mcp.is_some() => {
|
||||
resolver
|
||||
.assigned_mcp_servers(&input.project.root, agent.id)
|
||||
.await?
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
// 5a. ── INJECTION DE LA CONF MCP (cadrage v3, Décision 3) ──
|
||||
// Strictement APRÈS le convention file (étape 5) et AVANT le spawn /
|
||||
// `factory.start` (étapes 5b/6). Si le profil porte une `McpCapability`,
|
||||
@ -2497,6 +2545,7 @@ impl LaunchAgent {
|
||||
&input.project.root,
|
||||
input.mcp_runtime.as_ref(),
|
||||
effective_permissions.as_ref(),
|
||||
&plugin_mcp_servers,
|
||||
&mut spec,
|
||||
)
|
||||
.await
|
||||
@ -2528,6 +2577,12 @@ impl LaunchAgent {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// Claude's permission projector owns `settings.local.json` and writes its
|
||||
// MCP allow-list after `.mcp.json`; extend that allow-list with the exact
|
||||
// assigned server names once projection is complete.
|
||||
self.enable_claude_plugin_mcp_servers(&profile, &run_dir, &plugin_mcp_servers)
|
||||
.await;
|
||||
|
||||
// 5d. ── PLAN DE SANDBOX OS (lot LP4-3) ──
|
||||
// Strictement APRÈS la résolution des permissions (3) et la projection
|
||||
// advisory (5c), AVANT le split structuré/PTY : `compile_sandbox_plan`
|
||||
@ -2570,6 +2625,7 @@ impl LaunchAgent {
|
||||
enrich_sandbox_plan_with_rw_roots(spec.sandbox, &codex_permission_writable_roots);
|
||||
spec.sandbox =
|
||||
enrich_sandbox_plan_with_managed_temp_roots(spec.sandbox, &managed_temp_writable_roots);
|
||||
spec.sandbox = enrich_sandbox_plan_with_plugin_mcp_roots(spec.sandbox, &plugin_mcp_servers);
|
||||
append_codex_pty_managed_temp_roots(
|
||||
&profile,
|
||||
effective_permissions.as_ref(),
|
||||
@ -3078,6 +3134,63 @@ impl LaunchAgent {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn enable_claude_plugin_mcp_servers(
|
||||
&self,
|
||||
profile: &AgentProfile,
|
||||
run_dir: &ProjectPath,
|
||||
plugin_servers: &[PluginMcpServerSpec],
|
||||
) {
|
||||
if select_projector_key(profile) != Some(ProjectorKey::Claude) {
|
||||
return;
|
||||
}
|
||||
let path = RemotePath::new(join(run_dir, ".claude/settings.local.json"));
|
||||
let Ok(bytes) = self.fs.read(&path).await else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut document) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
|
||||
return;
|
||||
};
|
||||
let Some(root) = document.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
if plugin_servers.is_empty()
|
||||
&& !root
|
||||
.get("enabledMcpjsonServers")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|values| {
|
||||
values.iter().any(|value| {
|
||||
value
|
||||
.as_str()
|
||||
.is_some_and(|name| name.starts_with("idea_plugin__"))
|
||||
})
|
||||
})
|
||||
{
|
||||
return;
|
||||
}
|
||||
let enabled = root
|
||||
.entry("enabledMcpjsonServers")
|
||||
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
|
||||
let Some(enabled) = enabled.as_array_mut() else {
|
||||
return;
|
||||
};
|
||||
enabled.retain(|value| {
|
||||
!value
|
||||
.as_str()
|
||||
.is_some_and(|name| name.starts_with("idea_plugin__"))
|
||||
});
|
||||
for server in plugin_servers {
|
||||
let name = server.client_name();
|
||||
if !enabled.iter().any(|value| value.as_str() == Some(&name)) {
|
||||
enabled.push(serde_json::Value::String(name));
|
||||
}
|
||||
}
|
||||
let Ok(mut body) = serde_json::to_vec_pretty(&document) else {
|
||||
return;
|
||||
};
|
||||
body.push(b'\n');
|
||||
let _ = self.fs.write(&path, &body).await;
|
||||
}
|
||||
|
||||
async fn resolve_effective_permissions(
|
||||
&self,
|
||||
project: &Project,
|
||||
@ -3186,6 +3299,7 @@ impl LaunchAgent {
|
||||
project_root: &ProjectPath,
|
||||
runtime: Option<&McpRuntime>,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
plugin_servers: &[PluginMcpServerSpec],
|
||||
spec: &mut SpawnSpec,
|
||||
) -> Result<(), AppError> {
|
||||
let Some(mcp) = &profile.mcp else {
|
||||
@ -3194,7 +3308,7 @@ impl LaunchAgent {
|
||||
match &mcp.config {
|
||||
domain::profile::McpConfigStrategy::ConfigFile { target } => {
|
||||
let path = RemotePath::new(join(run_dir, target));
|
||||
let declaration = mcp_server_declaration(mcp.transport, runtime);
|
||||
let declaration = mcp_server_declaration(mcp.transport, runtime, plugin_servers);
|
||||
match runtime {
|
||||
// Real launch (app-tauri injected the runtime): the declaration
|
||||
// carries the **stable** IdeA executable path (`$APPIMAGE`, resolved
|
||||
@ -3214,7 +3328,15 @@ impl LaunchAgent {
|
||||
// overwrite a real declaration previously written by an app-tauri
|
||||
// launch with the minimal one.
|
||||
None => match self.fs.exists(&path).await {
|
||||
Ok(true) => {}
|
||||
Ok(true) => {
|
||||
if let Ok(existing) = self.fs.read(&path).await {
|
||||
if let Some(rendered) =
|
||||
reconcile_plugin_mcp_json(&existing, plugin_servers)
|
||||
{
|
||||
let _ = self.fs.write(&path, rendered.as_bytes()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
let _ = self.fs.write(&path, declaration.as_bytes()).await;
|
||||
}
|
||||
@ -3229,7 +3351,7 @@ impl LaunchAgent {
|
||||
// le **dossier parent** de ce fichier. Codex lit alors ses serveurs MCP
|
||||
// dans ce `config.toml` ISOLÉ au run dir, jamais le `~/.codex` global.
|
||||
let path = RemotePath::new(join(run_dir, target));
|
||||
let declaration = mcp_server_wiring(mcp.transport, runtime).to_config_toml();
|
||||
let declaration = codex_mcp_declaration(mcp.transport, runtime, plugin_servers);
|
||||
// Même régime clobber/non-clobber que `.mcp.json` : avec un runtime réel
|
||||
// (lancement app-tauri) on régénère/clobber à chaque (re)lancement (l'exe
|
||||
// `$APPIMAGE` et l'endpoint dérivent entre runs) ; sans runtime
|
||||
@ -3250,7 +3372,21 @@ impl LaunchAgent {
|
||||
let _ = self.fs.write(&path, rendered.as_bytes()).await;
|
||||
}
|
||||
None => match self.fs.exists(&path).await {
|
||||
Ok(true) => {}
|
||||
Ok(true) => {
|
||||
if let Ok(existing) = self.fs.read(&path).await {
|
||||
if let Ok(existing) = String::from_utf8(existing) {
|
||||
if !plugin_servers.is_empty()
|
||||
|| existing.contains("[mcp_servers.\"idea_plugin__")
|
||||
{
|
||||
let rendered = merge_codex_plugin_mcp_tables(
|
||||
&existing,
|
||||
plugin_servers,
|
||||
);
|
||||
let _ = self.fs.write(&path, rendered.as_bytes()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
let rendered = codex_config_toml(
|
||||
None,
|
||||
@ -3278,7 +3414,14 @@ impl LaunchAgent {
|
||||
return Ok(());
|
||||
}
|
||||
let body = if let Some(opencode) = profile.opencode.as_ref() {
|
||||
opencode_config_json(opencode, project_root.as_str(), runtime, eff).to_string()
|
||||
opencode_config_json(
|
||||
opencode,
|
||||
project_root.as_str(),
|
||||
runtime,
|
||||
eff,
|
||||
plugin_servers,
|
||||
)
|
||||
.to_string()
|
||||
} else if let Some(provider) = profile.opencode_provider.as_ref() {
|
||||
let api_key = self.resolve_opencode_provider_api_key(provider).await?;
|
||||
opencode_provider_config_json(
|
||||
@ -3287,6 +3430,7 @@ impl LaunchAgent {
|
||||
project_root.as_str(),
|
||||
runtime,
|
||||
eff,
|
||||
plugin_servers,
|
||||
)
|
||||
.to_string()
|
||||
} else {
|
||||
@ -3525,8 +3669,98 @@ fn reattach_decision(
|
||||
fn mcp_server_declaration(
|
||||
transport: domain::profile::McpTransport,
|
||||
runtime: Option<&McpRuntime>,
|
||||
plugin_servers: &[PluginMcpServerSpec],
|
||||
) -> String {
|
||||
mcp_server_wiring(transport, runtime).to_mcp_json()
|
||||
let mut document: serde_json::Value =
|
||||
serde_json::from_str(&mcp_server_wiring(transport, runtime).to_mcp_json())
|
||||
.expect("the domain MCP encoder always produces valid JSON");
|
||||
let servers = document["mcpServers"]
|
||||
.as_object_mut()
|
||||
.expect("the domain MCP encoder always produces an mcpServers object");
|
||||
for server in plugin_servers {
|
||||
servers.insert(server.client_name(), plugin_mcp_json_entry(server));
|
||||
}
|
||||
let mut rendered = serde_json::to_string_pretty(&document)
|
||||
.expect("MCP declaration contains only serializable values");
|
||||
rendered.push('\n');
|
||||
rendered
|
||||
}
|
||||
|
||||
fn plugin_mcp_json_entry(server: &PluginMcpServerSpec) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"command": server.command,
|
||||
"args": server.args,
|
||||
"env": server.env.iter().cloned().collect::<HashMap<_, _>>(),
|
||||
"cwd": server.cwd,
|
||||
"transport": server.transport,
|
||||
})
|
||||
}
|
||||
|
||||
fn reconcile_plugin_mcp_json(
|
||||
existing: &[u8],
|
||||
plugin_servers: &[PluginMcpServerSpec],
|
||||
) -> Option<String> {
|
||||
let mut document: serde_json::Value = serde_json::from_slice(existing).ok()?;
|
||||
let servers = document.get_mut("mcpServers")?.as_object_mut()?;
|
||||
if plugin_servers.is_empty() && !servers.keys().any(|name| name.starts_with("idea_plugin__")) {
|
||||
return None;
|
||||
}
|
||||
servers.retain(|name, _| !name.starts_with("idea_plugin__"));
|
||||
for server in plugin_servers {
|
||||
servers.insert(server.client_name(), plugin_mcp_json_entry(server));
|
||||
}
|
||||
let mut rendered = serde_json::to_string_pretty(&document).ok()?;
|
||||
rendered.push('\n');
|
||||
Some(rendered)
|
||||
}
|
||||
|
||||
fn codex_mcp_declaration(
|
||||
transport: domain::profile::McpTransport,
|
||||
runtime: Option<&McpRuntime>,
|
||||
plugin_servers: &[PluginMcpServerSpec],
|
||||
) -> String {
|
||||
let mut rendered = mcp_server_wiring(transport, runtime).to_config_toml();
|
||||
rendered.push_str(&codex_plugin_mcp_declaration(plugin_servers));
|
||||
rendered
|
||||
}
|
||||
|
||||
fn codex_plugin_mcp_declaration(plugin_servers: &[PluginMcpServerSpec]) -> String {
|
||||
let mut rendered = String::new();
|
||||
for server in plugin_servers {
|
||||
rendered.push('\n');
|
||||
rendered.push_str(&format!(
|
||||
"[mcp_servers.{}]\ncommand = {}\nargs = [{}]\nenv = {}\ncwd = {}\n",
|
||||
toml_string(&server.client_name()),
|
||||
toml_string(&server.command),
|
||||
server
|
||||
.args
|
||||
.iter()
|
||||
.map(|arg| toml_string(arg))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
toml_inline_string_map(&server.env),
|
||||
toml_string(&server.cwd),
|
||||
));
|
||||
}
|
||||
rendered
|
||||
}
|
||||
|
||||
fn merge_codex_plugin_mcp_tables(existing: &str, plugin_servers: &[PluginMcpServerSpec]) -> String {
|
||||
let mut rendered = remove_codex_plugin_mcp_tables(existing);
|
||||
rendered.push_str(&codex_plugin_mcp_declaration(plugin_servers));
|
||||
if !rendered.ends_with('\n') {
|
||||
rendered.push('\n');
|
||||
}
|
||||
rendered
|
||||
}
|
||||
|
||||
fn toml_inline_string_map(entries: &[(String, String)]) -> String {
|
||||
let body = entries
|
||||
.iter()
|
||||
.map(|(key, value)| format!("{} = {}", toml_string(key), toml_string(value)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!("{{ {body} }}")
|
||||
}
|
||||
|
||||
/// Builds the IdeA MCP server **wiring** (`command` + `args` + transport) shared by
|
||||
@ -3596,6 +3830,7 @@ fn opencode_config_json(
|
||||
project_root: &str,
|
||||
runtime: Option<&McpRuntime>,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
plugin_servers: &[PluginMcpServerSpec],
|
||||
) -> serde_json::Value {
|
||||
let model = config.model.as_str();
|
||||
let opencode_model = format!("llamacpp/{model}");
|
||||
@ -3651,15 +3886,7 @@ fn opencode_config_json(
|
||||
.collect::<Vec<_>>();
|
||||
root.insert(
|
||||
"mcp".to_owned(),
|
||||
serde_json::json!({
|
||||
"idea": {
|
||||
"type": "local",
|
||||
"command": command_array,
|
||||
"cwd": project_root,
|
||||
"enabled": true,
|
||||
"timeout": resolve_opencode_mcp_timeout_ms()
|
||||
}
|
||||
}),
|
||||
opencode_mcp_servers(command_array, project_root, plugin_servers),
|
||||
);
|
||||
if let Some(permission) = domain::opencode_permission_block(eff) {
|
||||
root.insert("permission".to_owned(), permission);
|
||||
@ -3684,6 +3911,7 @@ fn opencode_provider_config_json(
|
||||
project_root: &str,
|
||||
runtime: Option<&McpRuntime>,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
plugin_servers: &[PluginMcpServerSpec],
|
||||
) -> serde_json::Value {
|
||||
let mut root = serde_json::Map::new();
|
||||
root.insert(
|
||||
@ -3734,15 +3962,7 @@ fn opencode_provider_config_json(
|
||||
.collect::<Vec<_>>();
|
||||
root.insert(
|
||||
"mcp".to_owned(),
|
||||
serde_json::json!({
|
||||
"idea": {
|
||||
"type": "local",
|
||||
"command": command_array,
|
||||
"cwd": project_root,
|
||||
"enabled": true,
|
||||
"timeout": resolve_opencode_mcp_timeout_ms()
|
||||
}
|
||||
}),
|
||||
opencode_mcp_servers(command_array, project_root, plugin_servers),
|
||||
);
|
||||
if let Some(permission) = domain::opencode_permission_block(eff) {
|
||||
root.insert("permission".to_owned(), permission);
|
||||
@ -3750,6 +3970,40 @@ fn opencode_provider_config_json(
|
||||
serde_json::Value::Object(root)
|
||||
}
|
||||
|
||||
fn opencode_mcp_servers(
|
||||
idea_command: Vec<serde_json::Value>,
|
||||
project_root: &str,
|
||||
plugin_servers: &[PluginMcpServerSpec],
|
||||
) -> serde_json::Value {
|
||||
let mut servers = serde_json::Map::new();
|
||||
servers.insert(
|
||||
"idea".to_owned(),
|
||||
serde_json::json!({
|
||||
"type": "local",
|
||||
"command": idea_command,
|
||||
"cwd": project_root,
|
||||
"enabled": true,
|
||||
"timeout": resolve_opencode_mcp_timeout_ms()
|
||||
}),
|
||||
);
|
||||
for server in plugin_servers {
|
||||
let command = std::iter::once(server.command.clone())
|
||||
.chain(server.args.iter().cloned())
|
||||
.collect::<Vec<_>>();
|
||||
servers.insert(
|
||||
server.client_name(),
|
||||
serde_json::json!({
|
||||
"type": "local",
|
||||
"command": command,
|
||||
"environment": server.env.iter().cloned().collect::<HashMap<_, _>>(),
|
||||
"cwd": server.cwd,
|
||||
"enabled": true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
serde_json::Value::Object(servers)
|
||||
}
|
||||
|
||||
/// Builds an absolute path string by joining a [`ProjectPath`] with a relative
|
||||
/// segment using a POSIX separator.
|
||||
fn join(base: &ProjectPath, rel: &str) -> String {
|
||||
@ -3859,7 +4113,7 @@ fn codex_config_toml(
|
||||
run_dir: &str,
|
||||
project_root: &str,
|
||||
) -> String {
|
||||
let mut text = existing.unwrap_or_default().to_owned();
|
||||
let mut text = remove_codex_plugin_mcp_tables(existing.unwrap_or_default());
|
||||
text = replace_toml_table(&text, "mcp_servers.idea", mcp_declaration.trim_end());
|
||||
text = replace_toml_table(
|
||||
&text,
|
||||
@ -3874,6 +4128,25 @@ fn codex_config_toml(
|
||||
text
|
||||
}
|
||||
|
||||
fn remove_codex_plugin_mcp_tables(input: &str) -> String {
|
||||
let mut out = Vec::new();
|
||||
let mut skipping = false;
|
||||
for line in input.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("[mcp_servers.\"idea_plugin__") && trimmed.ends_with("\"]") {
|
||||
skipping = true;
|
||||
continue;
|
||||
}
|
||||
if skipping && trimmed.starts_with('[') {
|
||||
skipping = false;
|
||||
}
|
||||
if !skipping {
|
||||
out.push(line);
|
||||
}
|
||||
}
|
||||
out.join("\n")
|
||||
}
|
||||
|
||||
/// Merges a projector's **managed** TOML fragment into an existing `config.toml`
|
||||
/// (lot LP3-3, [`ProjectedFile::MergeToml`]). Only the `managed_tables` and
|
||||
/// `managed_keys` are touched; every other line of `existing` is preserved.
|
||||
@ -5045,7 +5318,7 @@ mod tests {
|
||||
project_id: "0123456789abcdef0123456789abcdef".to_owned(),
|
||||
requester: "11112222-3333-4444-5555-666677778888".to_owned(),
|
||||
};
|
||||
let decl = mcp_server_declaration(domain::profile::McpTransport::Stdio, Some(&rt));
|
||||
let decl = mcp_server_declaration(domain::profile::McpTransport::Stdio, Some(&rt), &[]);
|
||||
|
||||
// It is valid JSON with the IdeA server under `mcpServers/idea`.
|
||||
let parsed: serde_json::Value =
|
||||
@ -5069,7 +5342,7 @@ mod tests {
|
||||
// Launches issued from inside `application` (orchestrator/hot-swap/tests) have
|
||||
// no OS/runtime facts: the declaration falls back to the bare `idea mcp-server`
|
||||
// command — still a valid, self-consistent `mcpServers/idea` entry.
|
||||
let decl = mcp_server_declaration(domain::profile::McpTransport::Stdio, None);
|
||||
let decl = mcp_server_declaration(domain::profile::McpTransport::Stdio, None, &[]);
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&decl).expect("minimal declaration is valid JSON");
|
||||
let idea = &parsed["mcpServers"]["idea"];
|
||||
@ -5085,6 +5358,104 @@ mod tests {
|
||||
assert!(!args.contains(&"--endpoint"));
|
||||
}
|
||||
|
||||
fn assigned_plugin_server() -> PluginMcpServerSpec {
|
||||
PluginMcpServerSpec {
|
||||
plugin_id: domain::PluginId::new("com.idea.unity-tools").unwrap(),
|
||||
server_id: domain::PluginMcpServerId::new("unity-editor").unwrap(),
|
||||
identity: "plugin:com.idea.unity-tools:unity-editor".to_owned(),
|
||||
display_name: "Unity Editor".to_owned(),
|
||||
command: "/plugins/unity/server".to_owned(),
|
||||
args: vec!["--stdio".to_owned()],
|
||||
env: vec![("UNITY_SOCKET".to_owned(), "/tmp/unity.sock".to_owned())],
|
||||
cwd: "/plugins/unity".to_owned(),
|
||||
transport: "stdio".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assigned_plugin_server_is_materialized_for_claude_codex_and_opencode() {
|
||||
let server = assigned_plugin_server();
|
||||
let name = server.client_name();
|
||||
|
||||
let claude = mcp_server_declaration(
|
||||
domain::profile::McpTransport::Stdio,
|
||||
None,
|
||||
std::slice::from_ref(&server),
|
||||
);
|
||||
let claude: serde_json::Value = serde_json::from_str(&claude).unwrap();
|
||||
assert_eq!(claude["mcpServers"][&name]["command"], server.command);
|
||||
assert_eq!(claude["mcpServers"][&name]["args"][0], "--stdio");
|
||||
|
||||
let codex = codex_mcp_declaration(
|
||||
domain::profile::McpTransport::Stdio,
|
||||
None,
|
||||
std::slice::from_ref(&server),
|
||||
);
|
||||
assert!(codex.contains(&format!("[mcp_servers.{}]", toml_string(&name))));
|
||||
assert!(codex.contains("UNITY_SOCKET"));
|
||||
|
||||
let opencode = opencode_mcp_servers(
|
||||
vec!["idea".into(), "mcp-server".into()],
|
||||
"/project",
|
||||
std::slice::from_ref(&server),
|
||||
);
|
||||
assert_eq!(opencode[&name]["command"][0], server.command);
|
||||
assert_eq!(
|
||||
opencode[&name]["environment"]["UNITY_SOCKET"],
|
||||
"/tmp/unity.sock"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_plugin_server_projection_removes_stale_managed_tables() {
|
||||
let current = assigned_plugin_server();
|
||||
let stale = "idea_plugin__stale__server";
|
||||
let existing = format!(
|
||||
"user_key = \"keep\"\n[mcp_servers.\"{stale}\"]\ncommand = \"old\"\n\n[preview]\nenabled = true\n"
|
||||
);
|
||||
let declaration = codex_mcp_declaration(
|
||||
domain::profile::McpTransport::Stdio,
|
||||
None,
|
||||
std::slice::from_ref(¤t),
|
||||
);
|
||||
let rendered = codex_config_toml(Some(&existing), &declaration, "/run", "/project");
|
||||
assert!(!rendered.contains(stale));
|
||||
assert!(rendered.contains("user_key = \"keep\""));
|
||||
assert!(rendered.contains("[preview]\nenabled = true"));
|
||||
assert_eq!(rendered.matches(¤t.client_name()).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_restart_refreshes_plugin_servers_without_replacing_real_idea_wiring() {
|
||||
let current = assigned_plugin_server();
|
||||
let existing_json = br#"{
|
||||
"mcpServers": {
|
||||
"idea": { "command": "/opt/Idea.AppImage", "args": ["mcp-server", "--endpoint", "/live.sock"] },
|
||||
"idea_plugin__stale__server": { "command": "old" }
|
||||
}
|
||||
}"#;
|
||||
let claude = reconcile_plugin_mcp_json(existing_json, std::slice::from_ref(¤t))
|
||||
.expect("valid existing Claude config");
|
||||
let claude: serde_json::Value = serde_json::from_str(&claude).unwrap();
|
||||
assert_eq!(
|
||||
claude["mcpServers"]["idea"]["command"],
|
||||
"/opt/Idea.AppImage"
|
||||
);
|
||||
assert!(claude["mcpServers"]
|
||||
.get("idea_plugin__stale__server")
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
claude["mcpServers"][current.client_name()]["command"],
|
||||
current.command
|
||||
);
|
||||
|
||||
let existing_toml = "[mcp_servers.idea]\ncommand = \"/opt/Idea.AppImage\"\nargs = [\"mcp-server\", \"--endpoint\", \"/live.sock\"]\n\n[mcp_servers.\"idea_plugin__stale__server\"]\ncommand = \"old\"\n";
|
||||
let codex = merge_codex_plugin_mcp_tables(existing_toml, std::slice::from_ref(¤t));
|
||||
assert!(codex.contains("command = \"/opt/Idea.AppImage\""));
|
||||
assert!(!codex.contains("idea_plugin__stale__server"));
|
||||
assert!(codex.contains(¤t.client_name()));
|
||||
}
|
||||
|
||||
/// Builds a [`Handoff`] for the pure-section tests (lot P7).
|
||||
fn handoff(summary: &str, objective: Option<&str>) -> Handoff {
|
||||
Handoff::new(
|
||||
@ -5438,7 +5809,7 @@ command = "idea-mcp"
|
||||
domain::ports::SecretRef::new("secret-ref"),
|
||||
)
|
||||
.unwrap();
|
||||
let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None);
|
||||
let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None, &[]);
|
||||
let provider = &body["provider"]["anthropic"];
|
||||
assert_eq!(provider["options"]["apiKey"], "sk-live");
|
||||
assert!(provider["options"].get("baseURL").is_none());
|
||||
@ -5462,7 +5833,7 @@ command = "idea-mcp"
|
||||
.unwrap()
|
||||
.with_custom(custom);
|
||||
|
||||
let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None);
|
||||
let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None, &[]);
|
||||
let provider = &body["provider"]["my-custom"];
|
||||
assert_eq!(provider["options"]["apiKey"], "sk-live");
|
||||
assert_eq!(provider["options"]["baseURL"], "https://my-endpoint/v1");
|
||||
@ -5486,7 +5857,7 @@ command = "idea-mcp"
|
||||
.unwrap()
|
||||
.with_custom(custom);
|
||||
|
||||
let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None);
|
||||
let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None, &[]);
|
||||
assert_eq!(
|
||||
body["provider"]["my-custom"]["models"]["my-model"]["name"],
|
||||
"My Model"
|
||||
@ -5505,7 +5876,7 @@ command = "idea-mcp"
|
||||
OpenCodeProviderConfig::new("zai", "glm-5.1", domain::ports::SecretRef::new("ref"))
|
||||
.unwrap()
|
||||
.with_custom(custom);
|
||||
let body = opencode_provider_config_json(&config, "secret", "/project", None, None);
|
||||
let body = opencode_provider_config_json(&config, "secret", "/project", None, None, &[]);
|
||||
assert_eq!(body["model"], "zai/glm-5.1");
|
||||
assert_eq!(body["provider"]["zai"]["npm"], "@ai-sdk/openai-compatible");
|
||||
assert_eq!(
|
||||
@ -5535,7 +5906,7 @@ command = "idea-mcp"
|
||||
let config =
|
||||
domain::profile::OpenCodeConfig::new("http://localhost:8080", None, "m", None, None)
|
||||
.unwrap();
|
||||
let body = opencode_config_json(&config, "/project", None, None);
|
||||
let body = opencode_config_json(&config, "/project", None, None, &[]);
|
||||
assert!(body.get("permission").is_none());
|
||||
}
|
||||
|
||||
@ -5551,7 +5922,7 @@ command = "idea-mcp"
|
||||
requester: "11112222-3333-4444-5555-666677778888".to_owned(),
|
||||
};
|
||||
|
||||
let body = opencode_config_json(&config, "/project", Some(&runtime), None);
|
||||
let body = opencode_config_json(&config, "/project", Some(&runtime), None, &[]);
|
||||
let command = body["mcp"]["idea"]["command"]
|
||||
.as_array()
|
||||
.expect("OpenCode local command is an argv array")
|
||||
@ -5577,11 +5948,13 @@ command = "idea-mcp"
|
||||
.unwrap();
|
||||
|
||||
use domain::permission::Posture;
|
||||
let allowed = opencode_config_json(&config, "/project", None, Some(&eff(Posture::Allow)));
|
||||
let allowed =
|
||||
opencode_config_json(&config, "/project", None, Some(&eff(Posture::Allow)), &[]);
|
||||
assert_eq!(allowed["permission"]["bash"], "allow");
|
||||
assert_eq!(allowed["permission"]["edit"], "allow");
|
||||
|
||||
let denied = opencode_config_json(&config, "/project", None, Some(&eff(Posture::Deny)));
|
||||
let denied =
|
||||
opencode_config_json(&config, "/project", None, Some(&eff(Posture::Deny)), &[]);
|
||||
assert_eq!(denied["permission"]["bash"], "deny");
|
||||
assert_eq!(denied["permission"]["edit"], "deny");
|
||||
}
|
||||
@ -5594,7 +5967,7 @@ command = "idea-mcp"
|
||||
domain::ports::SecretRef::new("secret-ref"),
|
||||
)
|
||||
.unwrap();
|
||||
let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None);
|
||||
let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None, &[]);
|
||||
assert!(body.get("permission").is_none());
|
||||
}
|
||||
|
||||
@ -5613,6 +5986,7 @@ command = "idea-mcp"
|
||||
"/project",
|
||||
None,
|
||||
Some(&eff(domain::permission::Posture::Allow)),
|
||||
&[],
|
||||
);
|
||||
assert_eq!(allowed["permission"]["bash"], "allow");
|
||||
assert_eq!(allowed["permission"]["edit"], "allow");
|
||||
@ -5623,6 +5997,7 @@ command = "idea-mcp"
|
||||
"/project",
|
||||
None,
|
||||
Some(&eff(domain::permission::Posture::Deny)),
|
||||
&[],
|
||||
);
|
||||
assert_eq!(denied["permission"]["bash"], "deny");
|
||||
assert_eq!(denied["permission"]["edit"], "deny");
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
//! Project-scoped plugin activation and per-agent capability assignments.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
AgentContextStore, AgentId, AgentPluginAssignments, MarkdownDoc, PluginId, PluginManifest,
|
||||
PluginManifestValidator, PluginMcpServerId, PluginPackageStore, PluginRegistryStore,
|
||||
PluginSkillId, PluginSkillRef, PluginStoreError, PluginToolRef, Project,
|
||||
PluginManifestValidator, PluginMcpServerId, PluginMcpServerSpec, PluginPackageStore,
|
||||
PluginRegistryStore, PluginSkillId, PluginSkillRef, PluginStoreError, PluginToolRef, Project,
|
||||
ProjectPluginActivation, ProjectPluginSettings, ProjectPluginSettingsStore,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@ -119,6 +120,112 @@ impl ProjectPluginResolver {
|
||||
}
|
||||
Ok(MarkdownDoc::new(body))
|
||||
}
|
||||
|
||||
/// Resolves the runtime-active MCP servers that carry at least one tool
|
||||
/// explicitly assigned to `agent_id`.
|
||||
///
|
||||
/// The persisted [`PluginToolRef`] remains the authorization/source of intent;
|
||||
/// this projection only turns its owning server into launch-ready wiring for a
|
||||
/// native MCP client. Disabled, uninstalled, invalid, non-auto-start and
|
||||
/// unassigned servers are deliberately omitted.
|
||||
pub async fn assigned_mcp_servers(
|
||||
&self,
|
||||
project_root: &domain::ProjectPath,
|
||||
agent_id: domain::AgentId,
|
||||
) -> Result<Vec<PluginMcpServerSpec>, AppError> {
|
||||
let settings = self.settings(project_root).await?;
|
||||
let Some(assignments) = settings.agents.iter().find(|a| a.agent_id == agent_id) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let packages = self
|
||||
.packages
|
||||
.list_installed()
|
||||
.await
|
||||
.map_err(package_error)?;
|
||||
let app_data_dir = self.packages.app_data_dir_label();
|
||||
let mut seen = HashSet::new();
|
||||
let mut specs = Vec::new();
|
||||
|
||||
for reference in &assignments.tools {
|
||||
let key = (reference.plugin_id.clone(), reference.server_id.clone());
|
||||
if !seen.insert(key) {
|
||||
continue;
|
||||
}
|
||||
let Some(manifest) = self
|
||||
.enabled_manifest(project_root, &reference.plugin_id)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(server) = manifest
|
||||
.contributes
|
||||
.mcp_servers
|
||||
.into_iter()
|
||||
.find(|server| server.id == reference.server_id && server.auto_start)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(package_root) = packages
|
||||
.iter()
|
||||
.find(|package| package.plugin_id.as_ref() == Some(&reference.plugin_id))
|
||||
.map(|package| package.root.as_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let command =
|
||||
substitute_plugin_vars(&server.command, package_root, app_data_dir.as_deref());
|
||||
let command = if server.allow_absolute_command || looks_absolute(&command) {
|
||||
command
|
||||
} else {
|
||||
format!("{}/{}", package_root.trim_end_matches(['/', '\\']), command)
|
||||
};
|
||||
specs.push(PluginMcpServerSpec {
|
||||
identity: format!(
|
||||
"plugin:{}:{}",
|
||||
reference.plugin_id.as_str(),
|
||||
server.id.as_str()
|
||||
),
|
||||
plugin_id: reference.plugin_id.clone(),
|
||||
server_id: server.id,
|
||||
display_name: server.display_name,
|
||||
command,
|
||||
args: server
|
||||
.args
|
||||
.into_iter()
|
||||
.map(|arg| substitute_plugin_vars(&arg, package_root, app_data_dir.as_deref()))
|
||||
.collect(),
|
||||
env: server
|
||||
.env
|
||||
.into_iter()
|
||||
.map(|(key, value)| {
|
||||
(
|
||||
key,
|
||||
substitute_plugin_vars(&value, package_root, app_data_dir.as_deref()),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
cwd: substitute_plugin_vars(
|
||||
server.cwd.as_deref().unwrap_or("${pluginRoot}"),
|
||||
package_root,
|
||||
app_data_dir.as_deref(),
|
||||
),
|
||||
transport: server.transport,
|
||||
});
|
||||
}
|
||||
Ok(specs)
|
||||
}
|
||||
}
|
||||
|
||||
fn substitute_plugin_vars(raw: &str, plugin_root: &str, app_data_dir: Option<&str>) -> String {
|
||||
let value = raw.replace("${pluginRoot}", plugin_root);
|
||||
match app_data_dir {
|
||||
Some(app_data_dir) => value.replace("${appDataDir}", app_data_dir),
|
||||
None => value,
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_absolute(path: &str) -> bool {
|
||||
path.starts_with('/') || path.starts_with('\\') || path.as_bytes().get(1) == Some(&b':')
|
||||
}
|
||||
|
||||
/// Read model consumed by project settings surfaces.
|
||||
@ -437,3 +544,329 @@ pub fn plugin_tool_ref(
|
||||
tool_name,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
ContentHash, LocalPath, PluginActivationScope, PluginCapability, PluginContributionSet,
|
||||
PluginInstallSource, PluginLifecycleState, PluginManifestBytes, PluginManifestError,
|
||||
PluginMcpServerContribution, PluginPackageRef, PluginRegistry, PluginRegistryEntry,
|
||||
PluginTrustLevel, PluginVersion, ProjectPath, RelativePath, RemovalOutcome,
|
||||
StagedPluginPackage,
|
||||
};
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct MemorySettings {
|
||||
settings: Mutex<ProjectPluginSettings>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProjectPluginSettingsStore for MemorySettings {
|
||||
async fn load(
|
||||
&self,
|
||||
_project_root: &ProjectPath,
|
||||
) -> Result<ProjectPluginSettings, domain::StoreError> {
|
||||
Ok(self.settings.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
_project_root: &ProjectPath,
|
||||
settings: &ProjectPluginSettings,
|
||||
) -> Result<(), domain::StoreError> {
|
||||
*self.settings.lock().unwrap() = settings.clone();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct MemoryRegistry {
|
||||
registry: PluginRegistry,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginRegistryStore for MemoryRegistry {
|
||||
async fn load_registry(&self) -> Result<PluginRegistry, domain::PluginRegistryError> {
|
||||
Ok(self.registry.clone())
|
||||
}
|
||||
|
||||
async fn save_registry(
|
||||
&self,
|
||||
_registry: &PluginRegistry,
|
||||
) -> Result<(), domain::PluginRegistryError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct MemoryPackages {
|
||||
packages: Vec<PluginPackageRef>,
|
||||
app_data_dir: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginPackageStore for MemoryPackages {
|
||||
async fn list_installed(&self) -> Result<Vec<PluginPackageRef>, PluginStoreError> {
|
||||
Ok(self.packages.clone())
|
||||
}
|
||||
|
||||
async fn read_manifest(
|
||||
&self,
|
||||
_package: &PluginPackageRef,
|
||||
) -> Result<PluginManifestBytes, PluginStoreError> {
|
||||
Ok(PluginManifestBytes { bytes: Vec::new() })
|
||||
}
|
||||
|
||||
async fn install_from_archive(
|
||||
&self,
|
||||
_archive: &LocalPath,
|
||||
) -> Result<StagedPluginPackage, PluginStoreError> {
|
||||
Err(PluginStoreError::Invalid("not implemented".to_owned()))
|
||||
}
|
||||
|
||||
async fn install_from_directory(
|
||||
&self,
|
||||
_dir: &LocalPath,
|
||||
) -> Result<StagedPluginPackage, PluginStoreError> {
|
||||
Err(PluginStoreError::Invalid("not implemented".to_owned()))
|
||||
}
|
||||
|
||||
async fn commit_install(
|
||||
&self,
|
||||
_staged: StagedPluginPackage,
|
||||
_plugin_id: &PluginId,
|
||||
) -> Result<PluginPackageRef, PluginStoreError> {
|
||||
Err(PluginStoreError::Invalid("not implemented".to_owned()))
|
||||
}
|
||||
|
||||
async fn remove_package(
|
||||
&self,
|
||||
_plugin_id: &PluginId,
|
||||
) -> Result<RemovalOutcome, PluginStoreError> {
|
||||
Err(PluginStoreError::Invalid("not implemented".to_owned()))
|
||||
}
|
||||
|
||||
fn bundle_url(
|
||||
&self,
|
||||
_plugin_id: &PluginId,
|
||||
_entry: &RelativePath,
|
||||
_hash: &ContentHash,
|
||||
) -> Result<domain::PluginBundleUrl, PluginStoreError> {
|
||||
Err(PluginStoreError::Invalid("not implemented".to_owned()))
|
||||
}
|
||||
|
||||
fn app_data_dir_label(&self) -> Option<String> {
|
||||
self.app_data_dir.clone()
|
||||
}
|
||||
}
|
||||
|
||||
struct FixedManifest(PluginManifest);
|
||||
|
||||
impl PluginManifestValidator for FixedManifest {
|
||||
fn validate(
|
||||
&self,
|
||||
_bytes: &[u8],
|
||||
_package_root: &PluginPackageRef,
|
||||
) -> Result<PluginManifest, PluginManifestError> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_id() -> PluginId {
|
||||
PluginId::new("com.idea.unity-tools").unwrap()
|
||||
}
|
||||
|
||||
fn agent_id() -> AgentId {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(1))
|
||||
}
|
||||
|
||||
fn project_root() -> ProjectPath {
|
||||
ProjectPath::new("/project").unwrap()
|
||||
}
|
||||
|
||||
fn manifest(auto_start: bool) -> PluginManifest {
|
||||
PluginManifest {
|
||||
idea_plugin_manifest_version: 1,
|
||||
id: plugin_id(),
|
||||
display_name: "Unity Tools".to_owned(),
|
||||
publisher: None,
|
||||
version: PluginVersion::new("1.0.0").unwrap(),
|
||||
description: None,
|
||||
engine_idea: None,
|
||||
main: RelativePath::new("dist/index.js").unwrap(),
|
||||
icon: None,
|
||||
trust_level: PluginTrustLevel::Full,
|
||||
capabilities: vec![PluginCapability::Mcp],
|
||||
activation_scope: PluginActivationScope::Project,
|
||||
contributes: PluginContributionSet {
|
||||
mcp_servers: vec![
|
||||
PluginMcpServerContribution {
|
||||
id: PluginMcpServerId::new("unity-editor").unwrap(),
|
||||
display_name: "Unity Editor".to_owned(),
|
||||
command: "bin/server".to_owned(),
|
||||
args: vec!["--socket".to_owned(), "${appDataDir}/unity.sock".to_owned()],
|
||||
env: vec![("PLUGIN_ROOT".to_owned(), "${pluginRoot}".to_owned())],
|
||||
cwd: Some("${pluginRoot}/work".to_owned()),
|
||||
transport: "stdio".to_owned(),
|
||||
auto_start,
|
||||
allow_absolute_command: false,
|
||||
tools: Vec::new(),
|
||||
},
|
||||
PluginMcpServerContribution {
|
||||
id: PluginMcpServerId::new("unassigned").unwrap(),
|
||||
display_name: "Unassigned".to_owned(),
|
||||
command: "bin/other".to_owned(),
|
||||
args: Vec::new(),
|
||||
env: Vec::new(),
|
||||
cwd: None,
|
||||
transport: "stdio".to_owned(),
|
||||
auto_start: true,
|
||||
allow_absolute_command: false,
|
||||
tools: Vec::new(),
|
||||
},
|
||||
],
|
||||
..PluginContributionSet::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn registry(state: PluginLifecycleState) -> PluginRegistry {
|
||||
PluginRegistry {
|
||||
version: 1,
|
||||
plugins: vec![PluginRegistryEntry {
|
||||
id: plugin_id(),
|
||||
lifecycle_state: state,
|
||||
source: PluginInstallSource::Directory {
|
||||
path_label: "/source".to_owned(),
|
||||
},
|
||||
content_hash: ContentHash::new("abcdef").unwrap(),
|
||||
restart_required: false,
|
||||
error: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn settings(enabled: bool, tools: Vec<PluginToolRef>) -> ProjectPluginSettings {
|
||||
ProjectPluginSettings {
|
||||
version: 1,
|
||||
plugins: vec![ProjectPluginActivation {
|
||||
plugin_id: plugin_id(),
|
||||
enabled,
|
||||
}],
|
||||
agents: vec![AgentPluginAssignments {
|
||||
agent_id: agent_id(),
|
||||
skills: Vec::new(),
|
||||
tools,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn resolver(
|
||||
settings: ProjectPluginSettings,
|
||||
registry: PluginRegistry,
|
||||
manifest: PluginManifest,
|
||||
) -> ProjectPluginResolver {
|
||||
ProjectPluginResolver::new(
|
||||
Arc::new(MemorySettings {
|
||||
settings: Mutex::new(settings),
|
||||
}),
|
||||
Arc::new(MemoryPackages {
|
||||
packages: vec![PluginPackageRef {
|
||||
plugin_id: Some(plugin_id()),
|
||||
root: "/plugins/unity".to_owned(),
|
||||
}],
|
||||
app_data_dir: Some("/app-data".to_owned()),
|
||||
}),
|
||||
Arc::new(MemoryRegistry { registry }),
|
||||
Arc::new(FixedManifest(manifest)),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn assigned_mcp_servers_project_assigned_active_autostart_server_only() {
|
||||
let tool = PluginToolRef {
|
||||
plugin_id: plugin_id(),
|
||||
server_id: PluginMcpServerId::new("unity-editor").unwrap(),
|
||||
tool_name: "unity_detect_project".to_owned(),
|
||||
};
|
||||
let resolver = resolver(
|
||||
settings(true, vec![tool]),
|
||||
registry(PluginLifecycleState::Enabled),
|
||||
manifest(true),
|
||||
);
|
||||
|
||||
let servers = resolver
|
||||
.assigned_mcp_servers(&project_root(), agent_id())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(servers.len(), 1);
|
||||
let server = &servers[0];
|
||||
assert_eq!(
|
||||
server.client_name(),
|
||||
"idea_plugin__com_2eidea_2eunity_2dtools__unity_2deditor"
|
||||
);
|
||||
assert_eq!(server.command, "/plugins/unity/bin/server");
|
||||
assert_eq!(
|
||||
server.args,
|
||||
vec!["--socket".to_owned(), "/app-data/unity.sock".to_owned()]
|
||||
);
|
||||
assert_eq!(
|
||||
server.env,
|
||||
vec![("PLUGIN_ROOT".to_owned(), "/plugins/unity".to_owned())]
|
||||
);
|
||||
assert_eq!(server.cwd, "/plugins/unity/work");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn assigned_mcp_servers_omits_unassigned_disabled_and_non_autostart_servers() {
|
||||
let assigned_tool = PluginToolRef {
|
||||
plugin_id: plugin_id(),
|
||||
server_id: PluginMcpServerId::new("unity-editor").unwrap(),
|
||||
tool_name: "unity_detect_project".to_owned(),
|
||||
};
|
||||
let unassigned_only = resolver(
|
||||
settings(true, Vec::new()),
|
||||
registry(PluginLifecycleState::Enabled),
|
||||
manifest(true),
|
||||
);
|
||||
assert!(unassigned_only
|
||||
.assigned_mcp_servers(&project_root(), agent_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
|
||||
let project_disabled = resolver(
|
||||
settings(false, vec![assigned_tool.clone()]),
|
||||
registry(PluginLifecycleState::Enabled),
|
||||
manifest(true),
|
||||
);
|
||||
assert!(project_disabled
|
||||
.assigned_mcp_servers(&project_root(), agent_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
|
||||
let globally_disabled = resolver(
|
||||
settings(true, vec![assigned_tool.clone()]),
|
||||
registry(PluginLifecycleState::Disabled),
|
||||
manifest(true),
|
||||
);
|
||||
assert!(globally_disabled
|
||||
.assigned_mcp_servers(&project_root(), agent_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
|
||||
let non_autostart = resolver(
|
||||
settings(true, vec![assigned_tool]),
|
||||
registry(PluginLifecycleState::Enabled),
|
||||
manifest(false),
|
||||
);
|
||||
assert!(non_autostart
|
||||
.assigned_mcp_servers(&project_root(), agent_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@ -3076,6 +3076,7 @@ impl BackendCore {
|
||||
Some(Arc::clone(&check_embedder_suggestion)),
|
||||
)
|
||||
.with_assigned_skill_resolver(Arc::clone(&assigned_skill_resolver))
|
||||
.with_plugin_mcp_resolver(Arc::clone(&project_plugin_resolver))
|
||||
.with_structured_routing_mode(StructuredRoutingMode::HumanPtyFallback)
|
||||
.with_permission_store(Arc::clone(&permission_store_port))
|
||||
.with_system_permission_store(Arc::clone(&system_permission_store_port))
|
||||
@ -3134,6 +3135,7 @@ impl BackendCore {
|
||||
Some(Arc::clone(&check_embedder_suggestion)),
|
||||
)
|
||||
.with_assigned_skill_resolver(Arc::clone(&assigned_skill_resolver))
|
||||
.with_plugin_mcp_resolver(Arc::clone(&project_plugin_resolver))
|
||||
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
|
||||
.with_permission_store(Arc::clone(&permission_store_port))
|
||||
.with_system_permission_store(Arc::clone(&system_permission_store_port))
|
||||
|
||||
@ -796,6 +796,36 @@ pub struct PluginMcpServerSpec {
|
||||
pub transport: String,
|
||||
}
|
||||
|
||||
impl PluginMcpServerSpec {
|
||||
/// Stable, collision-free MCP client name for this plugin server.
|
||||
///
|
||||
/// Plugin/server ids may contain punctuation that is awkward in TOML table
|
||||
/// paths and becomes part of the tool namespace in MCP clients. Encode every
|
||||
/// non-alphanumeric byte instead of merely replacing it, so distinct ids can
|
||||
/// never collapse onto the same session server name.
|
||||
#[must_use]
|
||||
pub fn client_name(&self) -> String {
|
||||
fn component(raw: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for byte in raw.bytes() {
|
||||
if byte.is_ascii_alphanumeric() {
|
||||
out.push(char::from(byte).to_ascii_lowercase());
|
||||
} else {
|
||||
out.push('_');
|
||||
out.push_str(&format!("{byte:02x}"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
format!(
|
||||
"idea_plugin__{}__{}",
|
||||
component(self.plugin_id.as_str()),
|
||||
component(self.server_id.as_str())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// MCP server status.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -862,6 +892,32 @@ mod tests {
|
||||
assert!(PluginLifecycleState::Enabled.is_runtime_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_mcp_client_name_is_stable_and_collision_free_for_punctuation() {
|
||||
fn spec(plugin: &str) -> PluginMcpServerSpec {
|
||||
PluginMcpServerSpec {
|
||||
plugin_id: PluginId::new(plugin).unwrap(),
|
||||
server_id: PluginMcpServerId::new("unity-editor").unwrap(),
|
||||
identity: String::new(),
|
||||
display_name: String::new(),
|
||||
command: String::new(),
|
||||
args: Vec::new(),
|
||||
env: Vec::new(),
|
||||
cwd: String::new(),
|
||||
transport: "stdio".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
spec("com.idea.unity").client_name(),
|
||||
"idea_plugin__com_2eidea_2eunity__unity_2deditor"
|
||||
);
|
||||
assert_ne!(
|
||||
spec("com.idea-unity").client_name(),
|
||||
spec("com.idea.unity").client_name()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_capabilities_serialize_public_manifest_names() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user