feat(tickets): checkpoint backend V1 du système de tickets (T1-T5)

Checkpoint de travail — QA formelle encore à venir (après reset Codex).
`cargo build` workspace OK, tests ticket/issue verts (domaine, store,
use cases, app-tauri 47/51). Le domaine s'appelle `Issue`, exposé
« ticket » côté MCP/UI.

- T1 domaine : entité Issue (statut, priorité, liens, carnet),
  events, ids, ports.
- T2 infra : store FS Markdown + allocator de références #N.
- T3 application : use cases Issue (create/read/list/update/status/
  priority/carnet/link/unlink/assign) + erreurs dédiées.
- T4 surface MCP : 10 outils publics idea_ticket_* (23 outils au
  total), mappés vers les use cases Issue.
- T5 app-tauri : commandes Tauri ticket_* + DTOs d'events Issue +
  câblage state.rs.

Dette de test PRÉ-EXISTANTE héritée de develop (4 tests app-tauri
mcp_e2e_loopback / mcp_serve_peer rouges) hors périmètre tickets,
non traitée ici.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 22:34:04 +02:00
parent a9653bc417
commit 8de7be01a8
22 changed files with 4395 additions and 50 deletions

View File

@ -30,6 +30,7 @@
pub mod jsonrpc;
pub mod server;
pub mod tickets;
pub mod tools;
pub mod transport;
@ -37,5 +38,6 @@ pub use jsonrpc::{
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION,
};
pub use server::McpServer;
pub use tickets::{TicketToolError, TicketToolProvider};
pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError};
pub use transport::{MemoryTransport, StdioTransport};

View File

@ -28,6 +28,7 @@ use super::jsonrpc::{
error_codes, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError,
JSONRPC_VERSION,
};
use super::tickets::TicketToolProvider;
use super::tools::{self, ToolMapError};
/// The MCP protocol version this server speaks (advertised on `initialize`).
@ -61,6 +62,9 @@ pub struct McpServer {
/// (fix race cold-launch, signal MCP). L'infra ne connaît pas `AgentId` : la
/// composition root parse le `requester`. `None` ⇒ no-op.
ready_sink: Option<Arc<dyn Fn(&str) + Send + Sync>>,
/// Optional public ticket provider. The MCP surface says `ticket`; the
/// provider maps those calls to application/domain `Issue` use cases.
ticket_tools: Option<Arc<dyn TicketToolProvider>>,
}
impl McpServer {
@ -75,6 +79,7 @@ impl McpServer {
events: None,
requester: String::new(),
ready_sink: None,
ticket_tools: None,
}
}
@ -99,6 +104,13 @@ impl McpServer {
self
}
/// Attaches the public ticket tool provider.
#[must_use]
pub fn with_ticket_tools(mut self, ticket_tools: Arc<dyn TicketToolProvider>) -> Self {
self.ticket_tools = Some(ticket_tools);
self
}
/// Returns a per-connection clone of this server tagged with the connected
/// peer's `requester` id (the loopback handshake's `requester`, cadrage v5 §1.4).
///
@ -115,6 +127,7 @@ impl McpServer {
events: self.events.clone(),
requester: requester.into(),
ready_sink: self.ready_sink.clone(),
ticket_tools: self.ticket_tools.clone(),
}
}
@ -333,6 +346,44 @@ impl McpServer {
task_len={task_len}",
);
if tools::is_ticket_tool(&name) {
let result = match &self.ticket_tools {
Some(provider) => {
provider
.handle_ticket_tool(&self.project, &self.requester, &name, arguments)
.await
}
None => Err(super::tickets::TicketToolError::new(
"notConfigured",
"ticket 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

@ -0,0 +1,238 @@
//! Public MCP ticket tools.
//!
//! The public surface says `ticket`, while the provider behind this trait maps
//! to the application/domain `Issue` use cases.
use async_trait::async_trait;
use domain::Project;
use serde_json::{json, Value};
use super::tools::ToolDef;
/// Error returned by an MCP ticket tool provider.
#[derive(Debug, Clone, thiserror::Error)]
#[error("{code}: {message}")]
pub struct TicketToolError {
/// Stable machine-readable code.
pub code: &'static str,
/// Human-readable message.
pub message: String,
}
impl TicketToolError {
/// Builds a typed ticket-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 ticket tools.
#[async_trait]
pub trait TicketToolProvider: Send + Sync {
/// Executes one public `idea_ticket_*` tool for `project`.
async fn handle_ticket_tool(
&self,
project: &Project,
requester: &str,
name: &str,
arguments: Value,
) -> Result<Value, TicketToolError>;
}
/// Returns true when `name` is one of the public ticket tools.
#[must_use]
pub fn is_ticket_tool(name: &str) -> bool {
matches!(
name,
"idea_ticket_create"
| "idea_ticket_read"
| "idea_ticket_list"
| "idea_ticket_update"
| "idea_ticket_update_status"
| "idea_ticket_update_priority"
| "idea_ticket_read_carnet"
| "idea_ticket_update_carnet"
| "idea_ticket_link"
| "idea_ticket_unlink"
)
}
/// Public ticket tool definitions advertised by `tools/list`.
#[must_use]
pub fn catalogue() -> Vec<ToolDef> {
let ticket_ref = json!({ "type": "string", "pattern": "^#[1-9][0-9]*$" });
let status = json!({ "type": "string", "enum": ["open", "inProgress", "QA", "closed"] });
let priority = json!({ "type": "string", "enum": ["low", "medium", "high", "critical"] });
let link_kind = json!({
"type": "string",
"enum": ["relatesTo", "blocks", "blockedBy", "duplicates", "dependsOn"]
});
let link = json!({
"type": "object",
"properties": {
"targetRef": ticket_ref.clone(),
"kind": link_kind.clone()
},
"required": ["targetRef", "kind"],
"additionalProperties": false
});
vec![
ToolDef {
name: "idea_ticket_create",
description: "Create an IdeA ticket in the current project and return it as JSON.",
input_schema: json!({
"type": "object",
"properties": {
"title": { "type": "string" },
"description": { "type": "string" },
"priority": priority.clone(),
"status": status.clone(),
"assignedAgentIds": { "type": "array", "items": { "type": "string", "format": "uuid" } },
"links": { "type": "array", "items": link.clone() }
},
"required": ["title"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_ticket_read",
description: "Read one IdeA ticket by public reference (#N).",
input_schema: json!({
"type": "object",
"properties": {
"ref": ticket_ref.clone(),
"includeCarnet": { "type": "boolean" }
},
"required": ["ref"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_ticket_list",
description: "List IdeA tickets in the current project.",
input_schema: json!({
"type": "object",
"properties": {
"status": status.clone(),
"priority": priority.clone(),
"assignedAgentId": { "type": "string", "format": "uuid" },
"text": { "type": "string" },
"limit": { "type": "integer", "minimum": 1 },
"cursor": { "type": "string" }
},
"additionalProperties": false
}),
},
ToolDef {
name: "idea_ticket_update",
description: "Update ticket fields using optimistic concurrency.",
input_schema: json!({
"type": "object",
"properties": {
"ref": ticket_ref.clone(),
"title": { "type": "string" },
"description": { "type": "string" },
"status": status.clone(),
"priority": priority.clone(),
"assignedAgentIds": { "type": "array", "items": { "type": "string", "format": "uuid" } },
"expectedVersion": { "type": "integer", "minimum": 1 }
},
"required": ["ref", "expectedVersion"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_ticket_update_status",
description: "Update a ticket status using optimistic concurrency.",
input_schema: json!({
"type": "object",
"properties": {
"ref": ticket_ref.clone(),
"status": status.clone(),
"expectedVersion": { "type": "integer", "minimum": 1 }
},
"required": ["ref", "status", "expectedVersion"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_ticket_update_priority",
description: "Update a ticket priority using optimistic concurrency.",
input_schema: json!({
"type": "object",
"properties": {
"ref": ticket_ref.clone(),
"priority": priority.clone(),
"expectedVersion": { "type": "integer", "minimum": 1 }
},
"required": ["ref", "priority", "expectedVersion"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_ticket_read_carnet",
description: "Read the editable carnet attached to a ticket.",
input_schema: json!({
"type": "object",
"properties": { "ref": ticket_ref.clone() },
"required": ["ref"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_ticket_update_carnet",
description: "Update a ticket carnet using optimistic concurrency.",
input_schema: json!({
"type": "object",
"properties": {
"ref": ticket_ref.clone(),
"carnet": { "type": "string" },
"expectedVersion": { "type": "integer", "minimum": 1 }
},
"required": ["ref", "carnet", "expectedVersion"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_ticket_link",
description: "Add a link from one ticket to another using optimistic concurrency.",
input_schema: json!({
"type": "object",
"properties": {
"ref": ticket_ref.clone(),
"targetRef": ticket_ref.clone(),
"kind": link_kind.clone(),
"expectedVersion": { "type": "integer", "minimum": 1 }
},
"required": ["ref", "targetRef", "kind", "expectedVersion"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_ticket_unlink",
description: "Remove a link from one ticket to another using optimistic concurrency.",
input_schema: json!({
"type": "object",
"properties": {
"ref": ticket_ref.clone(),
"targetRef": ticket_ref,
"kind": link_kind,
"expectedVersion": { "type": "integer", "minimum": 1 }
},
"required": ["ref", "targetRef", "expectedVersion"],
"additionalProperties": false
}),
},
]
}

View File

@ -61,7 +61,13 @@ pub fn tool_returns_reply(tool: &str) -> bool {
| "idea_memory_read"
| "idea_skill_read"
| "idea_workstate_read"
)
) || is_ticket_tool(tool)
}
/// Whether `tool` is a public ticket MCP tool.
#[must_use]
pub fn is_ticket_tool(tool: &str) -> bool {
super::tickets::is_ticket_tool(tool)
}
/// The full catalogue advertised on `tools/list`.
@ -70,7 +76,7 @@ pub fn tool_returns_reply(tool: &str) -> bool {
/// [`OrchestratorCommand`].
#[must_use]
pub fn catalogue() -> Vec<ToolDef> {
vec![
let mut tools = vec![
ToolDef {
name: "idea_list_agents",
description: "List the IdeA agents declared in the project's manifest. Returns the \
@ -255,7 +261,9 @@ pub fn catalogue() -> Vec<ToolDef> {
"additionalProperties": false
}),
},
]
];
tools.extend(super::tickets::catalogue());
tools
}
/// Maps an MCP `tools/call` (`name` + `arguments`) to a validated