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