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,
|
ContextInjection, ConversationId, ConversationParty, DomainEvent, EffectivePermissions,
|
||||||
Handoff, HandoffStore, ManagedTempKind, ManifestEntry, MarkdownDoc, MemoryIndexEntry,
|
Handoff, HandoffStore, ManagedTempKind, ManifestEntry, MarkdownDoc, MemoryIndexEntry,
|
||||||
MemoryType, NetworkPolicy, NodeId, OrchestrationCapabilitySnapshot, PermissionProjector,
|
MemoryType, NetworkPolicy, NodeId, OrchestrationCapabilitySnapshot, PermissionProjector,
|
||||||
ProfileId, Project, ProjectPath, ProjectedFile, ProjectionContext, ProjectorKey,
|
PluginMcpServerSpec, ProfileId, Project, ProjectPath, ProjectedFile, ProjectionContext,
|
||||||
ProviderSessionStore, PtySize, SessionId, SessionKind, SessionStatus, SkillKind,
|
ProjectorKey, ProviderSessionStore, PtySize, SessionId, SessionKind, SessionStatus, SkillKind,
|
||||||
TerminalSession, HANDOFF_SUMMARY_MAX_CHARS,
|
TerminalSession, HANDOFF_SUMMARY_MAX_CHARS,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -50,6 +50,7 @@ use crate::model_server::{
|
|||||||
EnsureLocalModelServer, EnsureLocalModelServerInput, ModelServerUseGuard,
|
EnsureLocalModelServer, EnsureLocalModelServerInput, ModelServerUseGuard,
|
||||||
};
|
};
|
||||||
use crate::project::project_context_path;
|
use crate::project::project_context_path;
|
||||||
|
use crate::project_plugin::ProjectPluginResolver;
|
||||||
use crate::skill::{AgentCapability, AssignedSkillResolver, ResolveAgentCapabilities};
|
use crate::skill::{AgentCapability, AssignedSkillResolver, ResolveAgentCapabilities};
|
||||||
use crate::terminal::{
|
use crate::terminal::{
|
||||||
stop_live_agent_runtime_in_project, CloseTerminal, LiveSessions, StructuredSessions,
|
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)
|
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(
|
fn enrich_sandbox_plan_with_rw_roots(
|
||||||
plan: Option<SandboxPlan>,
|
plan: Option<SandboxPlan>,
|
||||||
writable_roots: &[String],
|
writable_roots: &[String],
|
||||||
@ -1738,6 +1764,10 @@ pub struct LaunchAgent {
|
|||||||
/// Releases the primary launch-scoped managed temp directory when launch fails
|
/// Releases the primary launch-scoped managed temp directory when launch fails
|
||||||
/// before registration.
|
/// before registration.
|
||||||
managed_temp_releaser: Option<Arc<ReleaseManagedTemp>>,
|
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.
|
/// Explicit intent for structured profiles when structured ports are absent.
|
||||||
structured_routing_mode: StructuredRoutingMode,
|
structured_routing_mode: StructuredRoutingMode,
|
||||||
}
|
}
|
||||||
@ -1789,6 +1819,7 @@ impl LaunchAgent {
|
|||||||
managed_temp_resolver: None,
|
managed_temp_resolver: None,
|
||||||
managed_temp_allocator: None,
|
managed_temp_allocator: None,
|
||||||
managed_temp_releaser: None,
|
managed_temp_releaser: None,
|
||||||
|
plugin_mcp: None,
|
||||||
structured_routing_mode: StructuredRoutingMode::HumanPtyFallback,
|
structured_routing_mode: StructuredRoutingMode::HumanPtyFallback,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1811,6 +1842,14 @@ impl LaunchAgent {
|
|||||||
self
|
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.
|
/// Injects the local model-server ensure use case used by OpenCode profiles.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_local_model_server(mut self, ensure: Arc<EnsureLocalModelServer>) -> Self {
|
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) ──
|
// 5a. ── INJECTION DE LA CONF MCP (cadrage v3, Décision 3) ──
|
||||||
// Strictement APRÈS le convention file (étape 5) et AVANT le spawn /
|
// Strictement APRÈS le convention file (étape 5) et AVANT le spawn /
|
||||||
// `factory.start` (étapes 5b/6). Si le profil porte une `McpCapability`,
|
// `factory.start` (étapes 5b/6). Si le profil porte une `McpCapability`,
|
||||||
@ -2497,6 +2545,7 @@ impl LaunchAgent {
|
|||||||
&input.project.root,
|
&input.project.root,
|
||||||
input.mcp_runtime.as_ref(),
|
input.mcp_runtime.as_ref(),
|
||||||
effective_permissions.as_ref(),
|
effective_permissions.as_ref(),
|
||||||
|
&plugin_mcp_servers,
|
||||||
&mut spec,
|
&mut spec,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@ -2528,6 +2577,12 @@ impl LaunchAgent {
|
|||||||
return Err(err);
|
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) ──
|
// 5d. ── PLAN DE SANDBOX OS (lot LP4-3) ──
|
||||||
// Strictement APRÈS la résolution des permissions (3) et la projection
|
// Strictement APRÈS la résolution des permissions (3) et la projection
|
||||||
// advisory (5c), AVANT le split structuré/PTY : `compile_sandbox_plan`
|
// 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);
|
enrich_sandbox_plan_with_rw_roots(spec.sandbox, &codex_permission_writable_roots);
|
||||||
spec.sandbox =
|
spec.sandbox =
|
||||||
enrich_sandbox_plan_with_managed_temp_roots(spec.sandbox, &managed_temp_writable_roots);
|
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(
|
append_codex_pty_managed_temp_roots(
|
||||||
&profile,
|
&profile,
|
||||||
effective_permissions.as_ref(),
|
effective_permissions.as_ref(),
|
||||||
@ -3078,6 +3134,63 @@ impl LaunchAgent {
|
|||||||
Ok(())
|
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(
|
async fn resolve_effective_permissions(
|
||||||
&self,
|
&self,
|
||||||
project: &Project,
|
project: &Project,
|
||||||
@ -3186,6 +3299,7 @@ impl LaunchAgent {
|
|||||||
project_root: &ProjectPath,
|
project_root: &ProjectPath,
|
||||||
runtime: Option<&McpRuntime>,
|
runtime: Option<&McpRuntime>,
|
||||||
eff: Option<&EffectivePermissions>,
|
eff: Option<&EffectivePermissions>,
|
||||||
|
plugin_servers: &[PluginMcpServerSpec],
|
||||||
spec: &mut SpawnSpec,
|
spec: &mut SpawnSpec,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let Some(mcp) = &profile.mcp else {
|
let Some(mcp) = &profile.mcp else {
|
||||||
@ -3194,7 +3308,7 @@ impl LaunchAgent {
|
|||||||
match &mcp.config {
|
match &mcp.config {
|
||||||
domain::profile::McpConfigStrategy::ConfigFile { target } => {
|
domain::profile::McpConfigStrategy::ConfigFile { target } => {
|
||||||
let path = RemotePath::new(join(run_dir, 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 {
|
match runtime {
|
||||||
// Real launch (app-tauri injected the runtime): the declaration
|
// Real launch (app-tauri injected the runtime): the declaration
|
||||||
// carries the **stable** IdeA executable path (`$APPIMAGE`, resolved
|
// carries the **stable** IdeA executable path (`$APPIMAGE`, resolved
|
||||||
@ -3214,7 +3328,15 @@ impl LaunchAgent {
|
|||||||
// overwrite a real declaration previously written by an app-tauri
|
// overwrite a real declaration previously written by an app-tauri
|
||||||
// launch with the minimal one.
|
// launch with the minimal one.
|
||||||
None => match self.fs.exists(&path).await {
|
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) => {
|
Ok(false) => {
|
||||||
let _ = self.fs.write(&path, declaration.as_bytes()).await;
|
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
|
// le **dossier parent** de ce fichier. Codex lit alors ses serveurs MCP
|
||||||
// dans ce `config.toml` ISOLÉ au run dir, jamais le `~/.codex` global.
|
// dans ce `config.toml` ISOLÉ au run dir, jamais le `~/.codex` global.
|
||||||
let path = RemotePath::new(join(run_dir, target));
|
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
|
// 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
|
// (lancement app-tauri) on régénère/clobber à chaque (re)lancement (l'exe
|
||||||
// `$APPIMAGE` et l'endpoint dérivent entre runs) ; sans runtime
|
// `$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;
|
let _ = self.fs.write(&path, rendered.as_bytes()).await;
|
||||||
}
|
}
|
||||||
None => match self.fs.exists(&path).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) => {
|
Ok(false) => {
|
||||||
let rendered = codex_config_toml(
|
let rendered = codex_config_toml(
|
||||||
None,
|
None,
|
||||||
@ -3278,7 +3414,14 @@ impl LaunchAgent {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let body = if let Some(opencode) = profile.opencode.as_ref() {
|
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() {
|
} else if let Some(provider) = profile.opencode_provider.as_ref() {
|
||||||
let api_key = self.resolve_opencode_provider_api_key(provider).await?;
|
let api_key = self.resolve_opencode_provider_api_key(provider).await?;
|
||||||
opencode_provider_config_json(
|
opencode_provider_config_json(
|
||||||
@ -3287,6 +3430,7 @@ impl LaunchAgent {
|
|||||||
project_root.as_str(),
|
project_root.as_str(),
|
||||||
runtime,
|
runtime,
|
||||||
eff,
|
eff,
|
||||||
|
plugin_servers,
|
||||||
)
|
)
|
||||||
.to_string()
|
.to_string()
|
||||||
} else {
|
} else {
|
||||||
@ -3525,8 +3669,98 @@ fn reattach_decision(
|
|||||||
fn mcp_server_declaration(
|
fn mcp_server_declaration(
|
||||||
transport: domain::profile::McpTransport,
|
transport: domain::profile::McpTransport,
|
||||||
runtime: Option<&McpRuntime>,
|
runtime: Option<&McpRuntime>,
|
||||||
|
plugin_servers: &[PluginMcpServerSpec],
|
||||||
) -> String {
|
) -> 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
|
/// Builds the IdeA MCP server **wiring** (`command` + `args` + transport) shared by
|
||||||
@ -3596,6 +3830,7 @@ fn opencode_config_json(
|
|||||||
project_root: &str,
|
project_root: &str,
|
||||||
runtime: Option<&McpRuntime>,
|
runtime: Option<&McpRuntime>,
|
||||||
eff: Option<&EffectivePermissions>,
|
eff: Option<&EffectivePermissions>,
|
||||||
|
plugin_servers: &[PluginMcpServerSpec],
|
||||||
) -> serde_json::Value {
|
) -> serde_json::Value {
|
||||||
let model = config.model.as_str();
|
let model = config.model.as_str();
|
||||||
let opencode_model = format!("llamacpp/{model}");
|
let opencode_model = format!("llamacpp/{model}");
|
||||||
@ -3651,15 +3886,7 @@ fn opencode_config_json(
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
root.insert(
|
root.insert(
|
||||||
"mcp".to_owned(),
|
"mcp".to_owned(),
|
||||||
serde_json::json!({
|
opencode_mcp_servers(command_array, project_root, plugin_servers),
|
||||||
"idea": {
|
|
||||||
"type": "local",
|
|
||||||
"command": command_array,
|
|
||||||
"cwd": project_root,
|
|
||||||
"enabled": true,
|
|
||||||
"timeout": resolve_opencode_mcp_timeout_ms()
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
if let Some(permission) = domain::opencode_permission_block(eff) {
|
if let Some(permission) = domain::opencode_permission_block(eff) {
|
||||||
root.insert("permission".to_owned(), permission);
|
root.insert("permission".to_owned(), permission);
|
||||||
@ -3684,6 +3911,7 @@ fn opencode_provider_config_json(
|
|||||||
project_root: &str,
|
project_root: &str,
|
||||||
runtime: Option<&McpRuntime>,
|
runtime: Option<&McpRuntime>,
|
||||||
eff: Option<&EffectivePermissions>,
|
eff: Option<&EffectivePermissions>,
|
||||||
|
plugin_servers: &[PluginMcpServerSpec],
|
||||||
) -> serde_json::Value {
|
) -> serde_json::Value {
|
||||||
let mut root = serde_json::Map::new();
|
let mut root = serde_json::Map::new();
|
||||||
root.insert(
|
root.insert(
|
||||||
@ -3734,15 +3962,7 @@ fn opencode_provider_config_json(
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
root.insert(
|
root.insert(
|
||||||
"mcp".to_owned(),
|
"mcp".to_owned(),
|
||||||
serde_json::json!({
|
opencode_mcp_servers(command_array, project_root, plugin_servers),
|
||||||
"idea": {
|
|
||||||
"type": "local",
|
|
||||||
"command": command_array,
|
|
||||||
"cwd": project_root,
|
|
||||||
"enabled": true,
|
|
||||||
"timeout": resolve_opencode_mcp_timeout_ms()
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
if let Some(permission) = domain::opencode_permission_block(eff) {
|
if let Some(permission) = domain::opencode_permission_block(eff) {
|
||||||
root.insert("permission".to_owned(), permission);
|
root.insert("permission".to_owned(), permission);
|
||||||
@ -3750,6 +3970,40 @@ fn opencode_provider_config_json(
|
|||||||
serde_json::Value::Object(root)
|
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
|
/// Builds an absolute path string by joining a [`ProjectPath`] with a relative
|
||||||
/// segment using a POSIX separator.
|
/// segment using a POSIX separator.
|
||||||
fn join(base: &ProjectPath, rel: &str) -> String {
|
fn join(base: &ProjectPath, rel: &str) -> String {
|
||||||
@ -3859,7 +4113,7 @@ fn codex_config_toml(
|
|||||||
run_dir: &str,
|
run_dir: &str,
|
||||||
project_root: &str,
|
project_root: &str,
|
||||||
) -> String {
|
) -> 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, "mcp_servers.idea", mcp_declaration.trim_end());
|
||||||
text = replace_toml_table(
|
text = replace_toml_table(
|
||||||
&text,
|
&text,
|
||||||
@ -3874,6 +4128,25 @@ fn codex_config_toml(
|
|||||||
text
|
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`
|
/// Merges a projector's **managed** TOML fragment into an existing `config.toml`
|
||||||
/// (lot LP3-3, [`ProjectedFile::MergeToml`]). Only the `managed_tables` and
|
/// (lot LP3-3, [`ProjectedFile::MergeToml`]). Only the `managed_tables` and
|
||||||
/// `managed_keys` are touched; every other line of `existing` is preserved.
|
/// `managed_keys` are touched; every other line of `existing` is preserved.
|
||||||
@ -5045,7 +5318,7 @@ mod tests {
|
|||||||
project_id: "0123456789abcdef0123456789abcdef".to_owned(),
|
project_id: "0123456789abcdef0123456789abcdef".to_owned(),
|
||||||
requester: "11112222-3333-4444-5555-666677778888".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`.
|
// It is valid JSON with the IdeA server under `mcpServers/idea`.
|
||||||
let parsed: serde_json::Value =
|
let parsed: serde_json::Value =
|
||||||
@ -5069,7 +5342,7 @@ mod tests {
|
|||||||
// Launches issued from inside `application` (orchestrator/hot-swap/tests) have
|
// Launches issued from inside `application` (orchestrator/hot-swap/tests) have
|
||||||
// no OS/runtime facts: the declaration falls back to the bare `idea mcp-server`
|
// no OS/runtime facts: the declaration falls back to the bare `idea mcp-server`
|
||||||
// command — still a valid, self-consistent `mcpServers/idea` entry.
|
// 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 =
|
let parsed: serde_json::Value =
|
||||||
serde_json::from_str(&decl).expect("minimal declaration is valid JSON");
|
serde_json::from_str(&decl).expect("minimal declaration is valid JSON");
|
||||||
let idea = &parsed["mcpServers"]["idea"];
|
let idea = &parsed["mcpServers"]["idea"];
|
||||||
@ -5085,6 +5358,104 @@ mod tests {
|
|||||||
assert!(!args.contains(&"--endpoint"));
|
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).
|
/// Builds a [`Handoff`] for the pure-section tests (lot P7).
|
||||||
fn handoff(summary: &str, objective: Option<&str>) -> Handoff {
|
fn handoff(summary: &str, objective: Option<&str>) -> Handoff {
|
||||||
Handoff::new(
|
Handoff::new(
|
||||||
@ -5438,7 +5809,7 @@ command = "idea-mcp"
|
|||||||
domain::ports::SecretRef::new("secret-ref"),
|
domain::ports::SecretRef::new("secret-ref"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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"];
|
let provider = &body["provider"]["anthropic"];
|
||||||
assert_eq!(provider["options"]["apiKey"], "sk-live");
|
assert_eq!(provider["options"]["apiKey"], "sk-live");
|
||||||
assert!(provider["options"].get("baseURL").is_none());
|
assert!(provider["options"].get("baseURL").is_none());
|
||||||
@ -5462,7 +5833,7 @@ command = "idea-mcp"
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.with_custom(custom);
|
.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"];
|
let provider = &body["provider"]["my-custom"];
|
||||||
assert_eq!(provider["options"]["apiKey"], "sk-live");
|
assert_eq!(provider["options"]["apiKey"], "sk-live");
|
||||||
assert_eq!(provider["options"]["baseURL"], "https://my-endpoint/v1");
|
assert_eq!(provider["options"]["baseURL"], "https://my-endpoint/v1");
|
||||||
@ -5486,7 +5857,7 @@ command = "idea-mcp"
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.with_custom(custom);
|
.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!(
|
assert_eq!(
|
||||||
body["provider"]["my-custom"]["models"]["my-model"]["name"],
|
body["provider"]["my-custom"]["models"]["my-model"]["name"],
|
||||||
"My Model"
|
"My Model"
|
||||||
@ -5505,7 +5876,7 @@ command = "idea-mcp"
|
|||||||
OpenCodeProviderConfig::new("zai", "glm-5.1", domain::ports::SecretRef::new("ref"))
|
OpenCodeProviderConfig::new("zai", "glm-5.1", domain::ports::SecretRef::new("ref"))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.with_custom(custom);
|
.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["model"], "zai/glm-5.1");
|
||||||
assert_eq!(body["provider"]["zai"]["npm"], "@ai-sdk/openai-compatible");
|
assert_eq!(body["provider"]["zai"]["npm"], "@ai-sdk/openai-compatible");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@ -5535,7 +5906,7 @@ command = "idea-mcp"
|
|||||||
let config =
|
let config =
|
||||||
domain::profile::OpenCodeConfig::new("http://localhost:8080", None, "m", None, None)
|
domain::profile::OpenCodeConfig::new("http://localhost:8080", None, "m", None, None)
|
||||||
.unwrap();
|
.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());
|
assert!(body.get("permission").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5551,7 +5922,7 @@ command = "idea-mcp"
|
|||||||
requester: "11112222-3333-4444-5555-666677778888".to_owned(),
|
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"]
|
let command = body["mcp"]["idea"]["command"]
|
||||||
.as_array()
|
.as_array()
|
||||||
.expect("OpenCode local command is an argv array")
|
.expect("OpenCode local command is an argv array")
|
||||||
@ -5577,11 +5948,13 @@ command = "idea-mcp"
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
use domain::permission::Posture;
|
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"]["bash"], "allow");
|
||||||
assert_eq!(allowed["permission"]["edit"], "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"]["bash"], "deny");
|
||||||
assert_eq!(denied["permission"]["edit"], "deny");
|
assert_eq!(denied["permission"]["edit"], "deny");
|
||||||
}
|
}
|
||||||
@ -5594,7 +5967,7 @@ command = "idea-mcp"
|
|||||||
domain::ports::SecretRef::new("secret-ref"),
|
domain::ports::SecretRef::new("secret-ref"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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());
|
assert!(body.get("permission").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5613,6 +5986,7 @@ command = "idea-mcp"
|
|||||||
"/project",
|
"/project",
|
||||||
None,
|
None,
|
||||||
Some(&eff(domain::permission::Posture::Allow)),
|
Some(&eff(domain::permission::Posture::Allow)),
|
||||||
|
&[],
|
||||||
);
|
);
|
||||||
assert_eq!(allowed["permission"]["bash"], "allow");
|
assert_eq!(allowed["permission"]["bash"], "allow");
|
||||||
assert_eq!(allowed["permission"]["edit"], "allow");
|
assert_eq!(allowed["permission"]["edit"], "allow");
|
||||||
@ -5623,6 +5997,7 @@ command = "idea-mcp"
|
|||||||
"/project",
|
"/project",
|
||||||
None,
|
None,
|
||||||
Some(&eff(domain::permission::Posture::Deny)),
|
Some(&eff(domain::permission::Posture::Deny)),
|
||||||
|
&[],
|
||||||
);
|
);
|
||||||
assert_eq!(denied["permission"]["bash"], "deny");
|
assert_eq!(denied["permission"]["bash"], "deny");
|
||||||
assert_eq!(denied["permission"]["edit"], "deny");
|
assert_eq!(denied["permission"]["edit"], "deny");
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
//! Project-scoped plugin activation and per-agent capability assignments.
|
//! Project-scoped plugin activation and per-agent capability assignments.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
AgentContextStore, AgentId, AgentPluginAssignments, MarkdownDoc, PluginId, PluginManifest,
|
AgentContextStore, AgentId, AgentPluginAssignments, MarkdownDoc, PluginId, PluginManifest,
|
||||||
PluginManifestValidator, PluginMcpServerId, PluginPackageStore, PluginRegistryStore,
|
PluginManifestValidator, PluginMcpServerId, PluginMcpServerSpec, PluginPackageStore,
|
||||||
PluginSkillId, PluginSkillRef, PluginStoreError, PluginToolRef, Project,
|
PluginRegistryStore, PluginSkillId, PluginSkillRef, PluginStoreError, PluginToolRef, Project,
|
||||||
ProjectPluginActivation, ProjectPluginSettings, ProjectPluginSettingsStore,
|
ProjectPluginActivation, ProjectPluginSettings, ProjectPluginSettingsStore,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@ -119,6 +120,112 @@ impl ProjectPluginResolver {
|
|||||||
}
|
}
|
||||||
Ok(MarkdownDoc::new(body))
|
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.
|
/// Read model consumed by project settings surfaces.
|
||||||
@ -437,3 +544,329 @@ pub fn plugin_tool_ref(
|
|||||||
tool_name,
|
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)),
|
Some(Arc::clone(&check_embedder_suggestion)),
|
||||||
)
|
)
|
||||||
.with_assigned_skill_resolver(Arc::clone(&assigned_skill_resolver))
|
.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_structured_routing_mode(StructuredRoutingMode::HumanPtyFallback)
|
||||||
.with_permission_store(Arc::clone(&permission_store_port))
|
.with_permission_store(Arc::clone(&permission_store_port))
|
||||||
.with_system_permission_store(Arc::clone(&system_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)),
|
Some(Arc::clone(&check_embedder_suggestion)),
|
||||||
)
|
)
|
||||||
.with_assigned_skill_resolver(Arc::clone(&assigned_skill_resolver))
|
.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_structured_routing_mode(StructuredRoutingMode::RequireStructured)
|
||||||
.with_permission_store(Arc::clone(&permission_store_port))
|
.with_permission_store(Arc::clone(&permission_store_port))
|
||||||
.with_system_permission_store(Arc::clone(&system_permission_store_port))
|
.with_system_permission_store(Arc::clone(&system_permission_store_port))
|
||||||
|
|||||||
@ -796,6 +796,36 @@ pub struct PluginMcpServerSpec {
|
|||||||
pub transport: String,
|
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.
|
/// MCP server status.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -862,6 +892,32 @@ mod tests {
|
|||||||
assert!(PluginLifecycleState::Enabled.is_runtime_active());
|
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]
|
#[test]
|
||||||
fn plugin_capabilities_serialize_public_manifest_names() {
|
fn plugin_capabilities_serialize_public_manifest_names() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
Reference in New Issue
Block a user