feat(infra): tmp partagé inter-projets géré par IdeA (handles opaques, leases, GC, projection sandbox) — #189 (QA verte)

- Domaine: port ManagedTempStore + modèle (handle opaque, kind Directory/File, état Active/Released, allocation/lease, expiry).
- Application: use cases Allocate/Resolve/Release/GcManagedTemp, horodatés via Clock.
- Infrastructure: impl FS FsManagedTempStore sous app_data_dir/managed-tmp (root global inter-projets, métadonnées par handle, GC des leases expirés/libérés).
- Exposition MCP contrôlée (ManagedTempToolProvider, catalogue) + câblage sandbox: les agents résolvent un handle au lieu d'inventer un path libre.
- Tests: application/infrastructure managed_temp.
This commit is contained in:
2026-08-13 13:34:33 +02:00
parent d7dfbcdfcb
commit fdca55ecd4
22 changed files with 2000 additions and 72 deletions

View File

@ -0,0 +1,122 @@
//! Public MCP managed temporary storage tools.
//!
//! The MCP surface exposes IdeA-managed temporary allocations without accepting
//! caller-supplied filesystem paths. A driving adapter injects the provider that
//! maps these calls to the application use cases.
use async_trait::async_trait;
use domain::Project;
use serde_json::{json, Value};
use super::tools::ToolDef;
/// Error returned by an MCP managed-temp tool provider.
#[derive(Debug, Clone, thiserror::Error)]
#[error("{code}: {message}")]
pub struct ManagedTempToolError {
/// Stable machine-readable code.
pub code: &'static str,
/// Human-readable message.
pub message: String,
}
impl ManagedTempToolError {
/// Builds a typed managed-temp tool error.
#[must_use]
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
/// Serialises the error as a camelCase JSON object.
#[must_use]
pub fn to_value(&self) -> Value {
json!({ "code": self.code, "message": self.message })
}
}
/// Provider injected by the composition root to execute public managed-temp tools.
#[async_trait]
pub trait ManagedTempToolProvider: Send + Sync {
/// Executes one public managed-temp tool for `project`.
async fn handle_managed_temp_tool(
&self,
project: &Project,
requester: &str,
name: &str,
arguments: Value,
) -> Result<Value, ManagedTempToolError>;
}
/// Returns true when `name` is one of the public managed-temp tools.
#[must_use]
pub fn is_managed_temp_tool(name: &str) -> bool {
matches!(
name,
"idea_managed_temp_allocate"
| "idea_managed_temp_resolve"
| "idea_managed_temp_release"
| "idea_managed_temp_gc"
)
}
/// Public managed-temp tool definitions advertised by `tools/list`.
#[must_use]
pub fn catalogue() -> Vec<ToolDef> {
let handle = json!({
"type": "string",
"pattern": "^[A-Za-z0-9_-]{1,128}$",
"description": "Opaque managed temporary handle returned by allocate."
});
vec![
ToolDef {
name: "idea_managed_temp_allocate",
description: "Allocate an IdeA-managed temporary file or directory and return its \
opaque handle plus metadata. The caller cannot provide a filesystem \
path.",
input_schema: json!({
"type": "object",
"properties": {
"kind": { "type": "string", "enum": ["directory", "file"] },
"purpose": { "type": "string", "description": "Short human/debug purpose." },
"ttlMs": { "type": "integer", "minimum": 1, "description": "Optional lease TTL in milliseconds." }
},
"required": ["kind", "purpose"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_managed_temp_resolve",
description: "Resolve an active IdeA-managed temporary handle. Returns the \
IdeA-controlled local path for the allocation data.",
input_schema: json!({
"type": "object",
"properties": { "handle": handle.clone() },
"required": ["handle"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_managed_temp_release",
description: "Release an IdeA-managed temporary handle, making it eligible for GC.",
input_schema: json!({
"type": "object",
"properties": { "handle": handle },
"required": ["handle"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_managed_temp_gc",
description: "Garbage-collect released or expired IdeA-managed temporary allocations.",
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
},
]
}

View File

@ -29,6 +29,7 @@
//! child process).
pub mod jsonrpc;
pub mod managed_temp;
pub mod policy;
pub mod server;
pub mod templates;
@ -39,6 +40,7 @@ pub mod transport;
pub use jsonrpc::{
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION,
};
pub use managed_temp::{ManagedTempToolError, ManagedTempToolProvider};
pub use policy::ToolPolicyRegistry;
pub use server::McpServer;
pub use templates::{TemplateToolError, TemplateToolProvider};

View File

@ -34,6 +34,7 @@ use super::jsonrpc::{
error_codes, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError,
JSONRPC_VERSION,
};
use super::managed_temp::ManagedTempToolProvider;
use super::policy::ToolPolicyRegistry;
use super::templates::TemplateToolProvider;
use super::tickets::TicketToolProvider;
@ -80,6 +81,9 @@ pub struct McpServer {
/// Optional public template provider. The MCP surface says `template`; the
/// provider maps those calls to application/domain `Template` use cases.
template_tools: Option<Arc<dyn TemplateToolProvider>>,
/// Optional public managed-temp provider. The MCP surface says `managed_temp`;
/// the provider maps those calls to application managed-temp use cases.
managed_temp_tools: Option<Arc<dyn ManagedTempToolProvider>>,
/// Optional per-requester MCP tool policy registry for constrained sessions.
tool_policies: Option<Arc<ToolPolicyRegistry>>,
/// Optional durable MCP tool permission store. When wired, absence of project or
@ -101,6 +105,7 @@ impl McpServer {
ready_sink: None,
ticket_tools: None,
template_tools: None,
managed_temp_tools: None,
tool_policies: None,
mcp_tool_permissions: None,
}
@ -141,6 +146,16 @@ impl McpServer {
self
}
/// Attaches the public managed-temp tool provider.
#[must_use]
pub fn with_managed_temp_tools(
mut self,
managed_temp_tools: Arc<dyn ManagedTempToolProvider>,
) -> Self {
self.managed_temp_tools = Some(managed_temp_tools);
self
}
/// Attaches the requester-scoped MCP tool policy registry.
#[must_use]
pub fn with_tool_policies(mut self, tool_policies: Arc<ToolPolicyRegistry>) -> Self {
@ -176,6 +191,7 @@ impl McpServer {
ready_sink: self.ready_sink.clone(),
ticket_tools: self.ticket_tools.clone(),
template_tools: self.template_tools.clone(),
managed_temp_tools: self.managed_temp_tools.clone(),
tool_policies: self.tool_policies.clone(),
mcp_tool_permissions: self.mcp_tool_permissions.clone(),
}
@ -543,6 +559,44 @@ impl McpServer {
};
}
if tools::is_managed_temp_tool(&name) {
let result = match &self.managed_temp_tools {
Some(provider) => {
provider
.handle_managed_temp_tool(&self.project, &self.requester, &name, arguments)
.await
}
None => Err(super::managed_temp::ManagedTempToolError::new(
"notConfigured",
"managed temp tools are not configured",
)),
};
self.publish_processed(&name, result.is_ok());
return match result {
Ok(value) => {
let text = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_owned());
application::diag!(
"[mcp] tools_call end tool={name} requester={requester_label} \
target={arg_target} ok=true is_error=false result_len={} elapsed_ms={}",
text.len(),
started.elapsed().as_millis(),
);
Ok(tool_result_text(&text, false))
}
Err(err) => {
let text =
serde_json::to_string(&err.to_value()).unwrap_or_else(|_| err.to_string());
application::diag!(
"[mcp] tools_call end tool={name} requester={requester_label} \
target={arg_target} ok=false is_error=true result_len={} elapsed_ms={}",
text.len(),
started.elapsed().as_millis(),
);
Ok(tool_result_text(&text, true))
}
};
}
// The handshake-provided requester is still passed to the mapper for tools that
// need peer identity.
let command = match tools::map_tool_call(&name, &arguments, &self.requester) {

View File

@ -57,6 +57,7 @@ pub const READ_ONLY_TOOLS: &[&str] = &[
"idea_sprint_list",
"idea_template_list",
"idea_template_read",
"idea_managed_temp_resolve",
];
/// Canonical write/action MCP tools denied by default.
@ -87,6 +88,9 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
"idea_template_create",
"idea_template_update",
"idea_template_delete",
"idea_managed_temp_allocate",
"idea_managed_temp_release",
"idea_managed_temp_gc",
];
/// All MCP tool names that have an explicit access classification.
@ -142,6 +146,7 @@ pub fn tool_returns_reply(tool: &str) -> bool {
| "idea_workstate_read"
) || is_ticket_tool(tool)
|| is_template_tool(tool)
|| is_managed_temp_tool(tool)
}
/// Whether `tool` is a public ticket MCP tool.
@ -156,6 +161,12 @@ pub fn is_template_tool(tool: &str) -> bool {
super::templates::is_template_tool(tool)
}
/// Whether `tool` is a public managed-temp MCP tool.
#[must_use]
pub fn is_managed_temp_tool(tool: &str) -> bool {
super::managed_temp::is_managed_temp_tool(tool)
}
/// The full catalogue advertised on `tools/list`.
///
/// Exactly the tools whose mapping target already exists as an
@ -415,6 +426,7 @@ pub fn catalogue() -> Vec<ToolDef> {
];
tools.extend(super::tickets::catalogue());
tools.extend(super::templates::catalogue());
tools.extend(super::managed_temp::catalogue());
tools
}
@ -1090,4 +1102,37 @@ mod tests {
"status enum must match WorkStatus labels exactly"
);
}
#[test]
fn catalogue_advertises_managed_temp_tools_with_expected_access() {
let cat = catalogue();
for tool in [
"idea_managed_temp_allocate",
"idea_managed_temp_resolve",
"idea_managed_temp_release",
"idea_managed_temp_gc",
] {
assert!(
cat.iter().any(|entry| entry.name == tool),
"catalogue must contain {tool}"
);
assert!(tool_returns_reply(tool), "{tool} returns JSON inline");
}
assert_eq!(
tool_access("idea_managed_temp_resolve"),
Some(McpToolAccess::Read)
);
assert_eq!(
tool_access("idea_managed_temp_allocate"),
Some(McpToolAccess::WriteAction)
);
assert_eq!(
tool_access("idea_managed_temp_release"),
Some(McpToolAccess::WriteAction)
);
assert_eq!(
tool_access("idea_managed_temp_gc"),
Some(McpToolAccess::WriteAction)
);
}
}