koprogo_api/infrastructure/web/handlers/
mcp_sse_handlers.rs

1/// MCP SSE Server Handler — Issue #252
2///
3/// Implements Model Context Protocol (MCP) version 2024-11-05 using JSON-RPC 2.0 over
4/// Server-Sent Events (SSE) transport.
5///
6/// Endpoints:
7///   GET  /mcp/sse        — SSE connection endpoint (client subscribes here)
8///   POST /mcp/messages   — JSON-RPC message endpoint (client sends requests here)
9///
10/// Authentication: JWT Bearer token (same as the rest of the API)
11///
12/// Protocol flow:
13///   1. Client opens SSE connection → receives `endpoint` event with POST URL
14///   2. Client sends JSON-RPC `initialize` request → receives capabilities
15///   3. Client calls `tools/list` → receives available KoproGo tools
16///   4. Client calls `tools/call` → tool executes and returns result
17///
18/// Reference: https://modelcontextprotocol.io/specification/2024-11-05/basic/transports/
19use crate::infrastructure::web::app_state::AppState;
20use crate::infrastructure::web::middleware::AuthenticatedUser;
21use actix_web::{
22    get, post,
23    web::{self, Data},
24    HttpRequest, HttpResponse,
25};
26use futures_util::stream::{self};
27use serde::{Deserialize, Serialize};
28use serde_json::{json, Value};
29use std::sync::Arc;
30use uuid::Uuid;
31
32// ─────────────────────────────────────────────────────────
33// JSON-RPC 2.0 types
34// ─────────────────────────────────────────────────────────
35
36/// Incoming JSON-RPC 2.0 request
37#[derive(Debug, Deserialize)]
38pub struct JsonRpcRequest {
39    pub jsonrpc: String,
40    pub id: Option<Value>,
41    pub method: String,
42    pub params: Option<Value>,
43}
44
45/// Outgoing JSON-RPC 2.0 response (success)
46#[derive(Debug, Serialize)]
47pub struct JsonRpcResponse {
48    pub jsonrpc: String,
49    pub id: Option<Value>,
50    pub result: Value,
51}
52
53/// Outgoing JSON-RPC 2.0 error
54#[derive(Debug, Serialize)]
55pub struct JsonRpcError {
56    pub jsonrpc: String,
57    pub id: Option<Value>,
58    pub error: RpcError,
59}
60
61#[derive(Debug, Serialize)]
62pub struct RpcError {
63    pub code: i32,
64    pub message: String,
65    pub data: Option<Value>,
66}
67
68/// JSON-RPC error codes (MCP standard)
69struct ErrorCode;
70impl ErrorCode {
71    const PARSE_ERROR: i32 = -32700;
72    const INVALID_REQUEST: i32 = -32600;
73    const METHOD_NOT_FOUND: i32 = -32601;
74    const INVALID_PARAMS: i32 = -32602;
75    const INTERNAL_ERROR: i32 = -32603;
76}
77
78// ─────────────────────────────────────────────────────────
79// MCP protocol types
80// ─────────────────────────────────────────────────────────
81
82/// Server info sent in `initialize` response
83#[derive(Debug, Serialize)]
84pub struct ServerInfo {
85    pub name: String,
86    pub version: String,
87}
88
89/// MCP capabilities advertised by the server
90#[derive(Debug, Serialize)]
91pub struct ServerCapabilities {
92    pub tools: ToolsCapability,
93}
94
95#[derive(Debug, Serialize)]
96pub struct ToolsCapability {
97    #[serde(rename = "listChanged")]
98    pub list_changed: bool,
99}
100
101/// MCP tool definition (for `tools/list` response)
102#[derive(Debug, Serialize, Clone)]
103pub struct McpTool {
104    pub name: String,
105    pub description: String,
106    #[serde(rename = "inputSchema")]
107    pub input_schema: Value,
108}
109
110/// Tool execution result (for `tools/call` response)
111#[derive(Debug, Serialize)]
112pub struct ToolResult {
113    pub content: Vec<ContentBlock>,
114    #[serde(rename = "isError", skip_serializing_if = "Option::is_none")]
115    pub is_error: Option<bool>,
116}
117
118#[derive(Debug, Serialize)]
119pub struct ContentBlock {
120    #[serde(rename = "type")]
121    pub content_type: String,
122    pub text: String,
123}
124
125// ─────────────────────────────────────────────────────────
126// Tool registry — KoproGo tools exposed via MCP
127// ─────────────────────────────────────────────────────────
128
129/// Returns all KoproGo tools available via MCP
130fn get_mcp_tools() -> Vec<McpTool> {
131    vec![
132        McpTool {
133            name: "list_buildings".to_string(),
134            description: "Liste tous les immeubles en copropriété de l'organisation. Retourne l'id, le nom, l'adresse, le nombre d'unités et l'état de chaque immeuble.".to_string(),
135            input_schema: json!({
136                "type": "object",
137                "properties": {
138                    "page": {
139                        "type": "integer",
140                        "description": "Numéro de page (défaut: 1)"
141                    },
142                    "per_page": {
143                        "type": "integer",
144                        "description": "Résultats par page (défaut: 20, max: 100)"
145                    }
146                },
147                "required": []
148            }),
149        },
150        McpTool {
151            name: "get_building".to_string(),
152            description: "Récupère les détails complets d'un immeuble: adresse, unités, syndic, informations légales, budget actif et statistiques financières.".to_string(),
153            input_schema: json!({
154                "type": "object",
155                "properties": {
156                    "building_id": {
157                        "type": "string",
158                        "format": "uuid",
159                        "description": "UUID de l'immeuble"
160                    }
161                },
162                "required": ["building_id"]
163            }),
164        },
165        McpTool {
166            name: "list_owners".to_string(),
167            description: "Liste les copropriétaires d'un immeuble avec leurs quotes-parts (tantièmes/millièmes), coordonnées et informations de contact.".to_string(),
168            input_schema: json!({
169                "type": "object",
170                "properties": {
171                    "building_id": {
172                        "type": "string",
173                        "format": "uuid",
174                        "description": "UUID de l'immeuble (optionnel, liste tous si absent)"
175                    }
176                },
177                "required": []
178            }),
179        },
180        McpTool {
181            name: "list_meetings".to_string(),
182            description: "Liste les assemblées générales (AG) d'un immeuble: date, type (AGO/AGE), statut, quorum validé, résolutions prises.".to_string(),
183            input_schema: json!({
184                "type": "object",
185                "properties": {
186                    "building_id": {
187                        "type": "string",
188                        "format": "uuid",
189                        "description": "UUID de l'immeuble"
190                    },
191                    "status": {
192                        "type": "string",
193                        "enum": ["Scheduled", "Completed", "Cancelled"],
194                        "description": "Filtrer par statut (optionnel)"
195                    }
196                },
197                "required": ["building_id"]
198            }),
199        },
200        McpTool {
201            name: "get_financial_summary".to_string(),
202            description: "Résumé financier d'un immeuble: charges totales, paiements en attente, budget approuvé vs réalisé, copropriétaires en retard de paiement.".to_string(),
203            input_schema: json!({
204                "type": "object",
205                "properties": {
206                    "building_id": {
207                        "type": "string",
208                        "format": "uuid",
209                        "description": "UUID de l'immeuble"
210                    }
211                },
212                "required": ["building_id"]
213            }),
214        },
215        McpTool {
216            name: "list_tickets".to_string(),
217            description: "Liste les tickets de maintenance d'un immeuble: interventions en cours, priorités, prestataires assignés, délais de résolution.".to_string(),
218            input_schema: json!({
219                "type": "object",
220                "properties": {
221                    "building_id": {
222                        "type": "string",
223                        "format": "uuid",
224                        "description": "UUID de l'immeuble"
225                    },
226                    "status": {
227                        "type": "string",
228                        "enum": ["Open", "Assigned", "InProgress", "Resolved", "Closed", "Cancelled"],
229                        "description": "Filtrer par statut (optionnel)"
230                    }
231                },
232                "required": ["building_id"]
233            }),
234        },
235        McpTool {
236            name: "get_owner_balance".to_string(),
237            description: "Solde et historique de paiements d'un copropriétaire: montants dus, paiements effectués, retards, relances envoyées.".to_string(),
238            input_schema: json!({
239                "type": "object",
240                "properties": {
241                    "owner_id": {
242                        "type": "string",
243                        "format": "uuid",
244                        "description": "UUID du copropriétaire"
245                    }
246                },
247                "required": ["owner_id"]
248            }),
249        },
250        McpTool {
251            name: "list_pending_expenses".to_string(),
252            description: "Liste les factures/charges en attente d'approbation pour un immeuble ou l'organisation entière. Inclut fournisseur, montant HT/TTC, TVA belge.".to_string(),
253            input_schema: json!({
254                "type": "object",
255                "properties": {
256                    "building_id": {
257                        "type": "string",
258                        "format": "uuid",
259                        "description": "UUID de l'immeuble (optionnel)"
260                    },
261                    "status": {
262                        "type": "string",
263                        "enum": ["Draft", "PendingApproval", "Approved", "Rejected", "Paid", "Overdue", "Cancelled"],
264                        "description": "Filtrer par statut (défaut: PendingApproval)"
265                    }
266                },
267                "required": []
268            }),
269        },
270        McpTool {
271            name: "check_quorum".to_string(),
272            description: "Vérifie si le quorum légal est atteint pour une assemblée générale (Art. 3.87 §5 CC belge: >50% des tantièmes présents/représentés).".to_string(),
273            input_schema: json!({
274                "type": "object",
275                "properties": {
276                    "meeting_id": {
277                        "type": "string",
278                        "format": "uuid",
279                        "description": "UUID de l'assemblée générale"
280                    }
281                },
282                "required": ["meeting_id"]
283            }),
284        },
285        McpTool {
286            name: "get_building_documents".to_string(),
287            description: "Liste les documents d'un immeuble: PV d'AG, contrats, devis, rapports d'inspection, budgets approuvés. Retourne les métadonnées et liens de téléchargement.".to_string(),
288            input_schema: json!({
289                "type": "object",
290                "properties": {
291                    "building_id": {
292                        "type": "string",
293                        "format": "uuid",
294                        "description": "UUID de l'immeuble"
295                    },
296                    "document_type": {
297                        "type": "string",
298                        "description": "Filtrer par type: Minutes, Contract, Invoice, Quote, Report, Budget, Other (optionnel)"
299                    }
300                },
301                "required": ["building_id"]
302            }),
303        },
304        McpTool {
305            name: "legal_search".to_string(),
306            description: "Recherche dans la base légale belge de copropriété par mot-clé ou code d'article. Retourne les articles du Code Civil pertinents avec explications.".to_string(),
307            input_schema: json!({
308                "type": "object",
309                "properties": {
310                    "query": {
311                        "type": "string",
312                        "description": "Mot-clé à rechercher (ex: 'quorum', 'majorité', 'convocation')"
313                    },
314                    "code": {
315                        "type": "string",
316                        "description": "Code d'article (ex: 'Art. 3.87 §1 CC') (optionnel)"
317                    },
318                    "category": {
319                        "type": "string",
320                        "description": "Catégorie légale: AG, Travaux, Majorité, Quorum, Convocation, Finances (optionnel)"
321                    }
322                },
323                "required": ["query"]
324            }),
325        },
326        McpTool {
327            name: "majority_calculator".to_string(),
328            description: "Calcule la majorité requise pour une décision d'assemblée générale selon la loi belge (Art. 3.88 CC). Retourne le type de majorité, le seuil exact et la base légale.".to_string(),
329            input_schema: json!({
330                "type": "object",
331                "properties": {
332                    "decision_type": {
333                        "type": "string",
334                        "enum": ["ordinary", "works_simple", "works_heavy", "statute_change", "unanimity"],
335                        "description": "Type de décision (AGO, travaux simples, travaux lourds, modification statuts, unanimité)"
336                    },
337                    "building_id": {
338                        "type": "string",
339                        "format": "uuid",
340                        "description": "UUID de l'immeuble (optionnel, pour contexte)"
341                    },
342                    "meeting_id": {
343                        "type": "string",
344                        "format": "uuid",
345                        "description": "UUID de l'assemblée (optionnel, pour contexte)"
346                    }
347                },
348                "required": ["decision_type"]
349            }),
350        },
351        McpTool {
352            name: "list_owners_of_building".to_string(),
353            description: "Liste détaillée des copropriétaires d'un immeuble avec tantièmes, statut actif/inactif, et historique de propriété. Alias spécialisé pour list_owners avec détails de bâtiment.".to_string(),
354            input_schema: json!({
355                "type": "object",
356                "properties": {
357                    "building_id": {
358                        "type": "string",
359                        "format": "uuid",
360                        "description": "UUID de l'immeuble"
361                    },
362                    "include_inactive": {
363                        "type": "boolean",
364                        "description": "Inclure les propriétaires inactifs/historiques (défaut: false)"
365                    }
366                },
367                "required": ["building_id"]
368            }),
369        },
370        McpTool {
371            name: "ag_quorum_check".to_string(),
372            description: "Vérifie le quorum légal et calcule la procédure de deuxième convocation (Art. 3.87 §3-4 CC). Retourne le statut quorum et les étapes suivantes si insuffisant.".to_string(),
373            input_schema: json!({
374                "type": "object",
375                "properties": {
376                    "meeting_id": {
377                        "type": "string",
378                        "format": "uuid",
379                        "description": "UUID de l'assemblée générale"
380                    }
381                },
382                "required": ["meeting_id"]
383            }),
384        },
385        McpTool {
386            name: "ag_vote".to_string(),
387            description: "Enregistre le vote d'un copropriétaire sur une résolution d'assemblée générale. Support vote direct et procuration.".to_string(),
388            input_schema: json!({
389                "type": "object",
390                "properties": {
391                    "resolution_id": {
392                        "type": "string",
393                        "format": "uuid",
394                        "description": "UUID de la résolution"
395                    },
396                    "choice": {
397                        "type": "string",
398                        "enum": ["Pour", "Contre", "Abstention"],
399                        "description": "Choix de vote"
400                    },
401                    "proxy_owner_id": {
402                        "type": "string",
403                        "format": "uuid",
404                        "description": "UUID du mandataire si vote par procuration (optionnel)"
405                    }
406                },
407                "required": ["resolution_id", "choice"]
408            }),
409        },
410        McpTool {
411            name: "comptabilite_situation".to_string(),
412            description: "Situation comptable d'un immeuble: soldes comptes, arriérés de charges, revenus, dépenses. Retourne bilan financier détaillé.".to_string(),
413            input_schema: json!({
414                "type": "object",
415                "properties": {
416                    "building_id": {
417                        "type": "string",
418                        "format": "uuid",
419                        "description": "UUID de l'immeuble"
420                    },
421                    "fiscal_year": {
422                        "type": "integer",
423                        "description": "Année fiscale (optionnel, défaut: année courante)"
424                    }
425                },
426                "required": ["building_id"]
427            }),
428        },
429        McpTool {
430            name: "appel_de_fonds".to_string(),
431            description: "Génère un appel de fonds auprès de tous les copropriétaires. Calcule automatiquement les quotes-parts individuelles et envoie convocations.".to_string(),
432            input_schema: json!({
433                "type": "object",
434                "properties": {
435                    "building_id": {
436                        "type": "string",
437                        "format": "uuid",
438                        "description": "UUID de l'immeuble"
439                    },
440                    "amount_cents": {
441                        "type": "integer",
442                        "description": "Montant total en centimes d'euros"
443                    },
444                    "due_date": {
445                        "type": "string",
446                        "format": "date",
447                        "description": "Date d'échéance (YYYY-MM-DD)"
448                    },
449                    "description": {
450                        "type": "string",
451                        "description": "Description du motif de l'appel (ex: 'Rénovation toiture')"
452                    }
453                },
454                "required": ["building_id", "amount_cents", "due_date", "description"]
455            }),
456        },
457        McpTool {
458            name: "travaux_qualifier".to_string(),
459            description: "Qualifie des travaux comme urgents/non-urgents et détermine la majorité requise selon montant et contexte (Art. 3.88-3.89 CC).".to_string(),
460            input_schema: json!({
461                "type": "object",
462                "properties": {
463                    "description": {
464                        "type": "string",
465                        "description": "Description des travaux"
466                    },
467                    "estimated_amount_eur": {
468                        "type": "number",
469                        "description": "Montant estimé en euros"
470                    },
471                    "is_emergency": {
472                        "type": "boolean",
473                        "description": "Travaux d'urgence? (conservatoires, sécurité)"
474                    }
475                },
476                "required": ["description", "estimated_amount_eur", "is_emergency"]
477            }),
478        },
479        McpTool {
480            name: "alertes_list".to_string(),
481            description: "Liste les alertes de conformité actives: mandats de syndic expirés, AG sans PV, paiements en retard, contrats expirés.".to_string(),
482            input_schema: json!({
483                "type": "object",
484                "properties": {
485                    "building_id": {
486                        "type": "string",
487                        "format": "uuid",
488                        "description": "UUID de l'immeuble (optionnel, liste tous si absent)"
489                    }
490                },
491                "required": []
492            }),
493        },
494        McpTool {
495            name: "energie_campagne_list".to_string(),
496            description: "Liste les campagnes d'achat groupé d'énergie de l'organisation: statut participation, offres reçues, économies estimées.".to_string(),
497            input_schema: json!({
498                "type": "object",
499                "properties": {
500                    "status": {
501                        "type": "string",
502                        "enum": ["Draft", "Active", "Completed", "Cancelled"],
503                        "description": "Filtrer par statut (optionnel)"
504                    }
505                },
506                "required": []
507            }),
508        },
509    ]
510}
511
512// ─────────────────────────────────────────────────────────
513// Tool dispatcher
514// ─────────────────────────────────────────────────────────
515
516/// Dispatches a `tools/call` request to the appropriate tool implementation.
517/// Returns a ToolResult or a JSON-RPC error.
518async fn dispatch_tool(
519    tool_name: &str,
520    arguments: &Value,
521    state: &AppState,
522    user: &AuthenticatedUser,
523) -> Result<ToolResult, RpcError> {
524    let org_id = match user.organization_id {
525        Some(id) => id,
526        None => {
527            return Err(RpcError {
528                code: ErrorCode::INVALID_REQUEST,
529                message: "User does not belong to an organization".to_string(),
530                data: None,
531            })
532        }
533    };
534
535    match tool_name {
536        "list_buildings" => {
537            let page = arguments.get("page").and_then(|v| v.as_u64()).unwrap_or(1) as i64;
538            let per_page = arguments
539                .get("per_page")
540                .and_then(|v| v.as_u64())
541                .unwrap_or(20) as i64;
542
543            let page_request = crate::application::dto::PageRequest {
544                page,
545                per_page,
546                sort_by: None,
547                order: crate::application::dto::SortOrder::default(),
548            };
549            match state
550                .building_use_cases
551                .list_buildings_paginated(&page_request, Some(org_id))
552                .await
553            {
554                Ok((buildings, _total)) => {
555                    let text = serde_json::to_string_pretty(&buildings)
556                        .unwrap_or_else(|_| "[]".to_string());
557                    Ok(ToolResult {
558                        content: vec![ContentBlock {
559                            content_type: "text".to_string(),
560                            text,
561                        }],
562                        is_error: None,
563                    })
564                }
565                Err(e) => Err(RpcError {
566                    code: ErrorCode::INTERNAL_ERROR,
567                    message: format!("Failed to list buildings: {}", e),
568                    data: None,
569                }),
570            }
571        }
572
573        "get_building" => {
574            let building_id_str = arguments
575                .get("building_id")
576                .and_then(|v| v.as_str())
577                .ok_or_else(|| RpcError {
578                    code: ErrorCode::INVALID_PARAMS,
579                    message: "building_id is required".to_string(),
580                    data: None,
581                })?;
582
583            let building_id = Uuid::parse_str(building_id_str).map_err(|_| RpcError {
584                code: ErrorCode::INVALID_PARAMS,
585                message: "building_id must be a valid UUID".to_string(),
586                data: None,
587            })?;
588
589            match state.building_use_cases.get_building(building_id).await {
590                Ok(Some(building)) => {
591                    let text = serde_json::to_string_pretty(&building)
592                        .unwrap_or_else(|_| "{}".to_string());
593                    Ok(ToolResult {
594                        content: vec![ContentBlock {
595                            content_type: "text".to_string(),
596                            text,
597                        }],
598                        is_error: None,
599                    })
600                }
601                Ok(None) => Err(RpcError {
602                    code: ErrorCode::INVALID_PARAMS,
603                    message: format!("Building not found: {}", building_id),
604                    data: None,
605                }),
606                Err(e) => Err(RpcError {
607                    code: ErrorCode::INTERNAL_ERROR,
608                    message: format!("Failed to get building: {}", e),
609                    data: None,
610                }),
611            }
612        }
613
614        "list_owners" => {
615            let _building_id = arguments
616                .get("building_id")
617                .and_then(|v| v.as_str())
618                .and_then(|s| Uuid::parse_str(s).ok());
619
620            let page_request = crate::application::dto::PageRequest {
621                page: 1,
622                per_page: 100,
623                sort_by: None,
624                order: crate::application::dto::SortOrder::default(),
625            };
626            match state
627                .owner_use_cases
628                .list_owners_paginated(&page_request, Some(org_id))
629                .await
630            {
631                Ok((owners, _total)) => {
632                    let text =
633                        serde_json::to_string_pretty(&owners).unwrap_or_else(|_| "[]".to_string());
634                    Ok(ToolResult {
635                        content: vec![ContentBlock {
636                            content_type: "text".to_string(),
637                            text,
638                        }],
639                        is_error: None,
640                    })
641                }
642                Err(e) => Err(RpcError {
643                    code: ErrorCode::INTERNAL_ERROR,
644                    message: format!("Failed to list owners: {}", e),
645                    data: None,
646                }),
647            }
648        }
649
650        "list_meetings" => {
651            let building_id_str = arguments
652                .get("building_id")
653                .and_then(|v| v.as_str())
654                .ok_or_else(|| RpcError {
655                    code: ErrorCode::INVALID_PARAMS,
656                    message: "building_id is required".to_string(),
657                    data: None,
658                })?;
659
660            let building_id = Uuid::parse_str(building_id_str).map_err(|_| RpcError {
661                code: ErrorCode::INVALID_PARAMS,
662                message: "building_id must be a valid UUID".to_string(),
663                data: None,
664            })?;
665
666            match state
667                .meeting_use_cases
668                .list_meetings_by_building(building_id)
669                .await
670            {
671                Ok(meetings) => {
672                    let text = serde_json::to_string_pretty(&meetings)
673                        .unwrap_or_else(|_| "[]".to_string());
674                    Ok(ToolResult {
675                        content: vec![ContentBlock {
676                            content_type: "text".to_string(),
677                            text,
678                        }],
679                        is_error: None,
680                    })
681                }
682                Err(e) => Err(RpcError {
683                    code: ErrorCode::INTERNAL_ERROR,
684                    message: format!("Failed to list meetings: {}", e),
685                    data: None,
686                }),
687            }
688        }
689
690        "get_financial_summary" => {
691            let building_id_str = arguments
692                .get("building_id")
693                .and_then(|v| v.as_str())
694                .ok_or_else(|| RpcError {
695                    code: ErrorCode::INVALID_PARAMS,
696                    message: "building_id is required".to_string(),
697                    data: None,
698                })?;
699
700            let building_id = Uuid::parse_str(building_id_str).map_err(|_| RpcError {
701                code: ErrorCode::INVALID_PARAMS,
702                message: "building_id must be a valid UUID".to_string(),
703                data: None,
704            })?;
705
706            // Gather expenses stats
707            let expenses_result = state
708                .expense_use_cases
709                .list_expenses_by_building(building_id)
710                .await;
711
712            match expenses_result {
713                Ok(expenses) => {
714                    use crate::domain::entities::{ApprovalStatus, PaymentStatus};
715
716                    let total_expenses: f64 = expenses.iter().map(|e| e.amount).sum();
717                    let pending_count = expenses
718                        .iter()
719                        .filter(|e| e.approval_status == ApprovalStatus::PendingApproval)
720                        .count();
721                    let overdue_count = expenses
722                        .iter()
723                        .filter(|e| e.payment_status == PaymentStatus::Overdue)
724                        .count();
725
726                    let summary = json!({
727                        "building_id": building_id,
728                        "total_expenses_eur": format!("{:.2}", total_expenses),
729                        "pending_approval_count": pending_count,
730                        "overdue_count": overdue_count,
731                        "total_expense_count": expenses.len()
732                    });
733
734                    let text =
735                        serde_json::to_string_pretty(&summary).unwrap_or_else(|_| "{}".to_string());
736                    Ok(ToolResult {
737                        content: vec![ContentBlock {
738                            content_type: "text".to_string(),
739                            text,
740                        }],
741                        is_error: None,
742                    })
743                }
744                Err(e) => Err(RpcError {
745                    code: ErrorCode::INTERNAL_ERROR,
746                    message: format!("Failed to get financial summary: {}", e),
747                    data: None,
748                }),
749            }
750        }
751
752        "list_tickets" => {
753            let building_id_str = arguments
754                .get("building_id")
755                .and_then(|v| v.as_str())
756                .ok_or_else(|| RpcError {
757                    code: ErrorCode::INVALID_PARAMS,
758                    message: "building_id is required".to_string(),
759                    data: None,
760                })?;
761
762            let building_id = Uuid::parse_str(building_id_str).map_err(|_| RpcError {
763                code: ErrorCode::INVALID_PARAMS,
764                message: "building_id must be a valid UUID".to_string(),
765                data: None,
766            })?;
767
768            match state
769                .ticket_use_cases
770                .list_tickets_by_building(building_id)
771                .await
772            {
773                Ok(tickets) => {
774                    let text =
775                        serde_json::to_string_pretty(&tickets).unwrap_or_else(|_| "[]".to_string());
776                    Ok(ToolResult {
777                        content: vec![ContentBlock {
778                            content_type: "text".to_string(),
779                            text,
780                        }],
781                        is_error: None,
782                    })
783                }
784                Err(e) => Err(RpcError {
785                    code: ErrorCode::INTERNAL_ERROR,
786                    message: format!("Failed to list tickets: {}", e),
787                    data: None,
788                }),
789            }
790        }
791
792        "get_owner_balance" => {
793            let owner_id_str = arguments
794                .get("owner_id")
795                .and_then(|v| v.as_str())
796                .ok_or_else(|| RpcError {
797                    code: ErrorCode::INVALID_PARAMS,
798                    message: "owner_id is required".to_string(),
799                    data: None,
800                })?;
801
802            let owner_id = Uuid::parse_str(owner_id_str).map_err(|_| RpcError {
803                code: ErrorCode::INVALID_PARAMS,
804                message: "owner_id must be a valid UUID".to_string(),
805                data: None,
806            })?;
807
808            match state
809                .owner_contribution_use_cases
810                .get_outstanding_contributions(owner_id)
811                .await
812            {
813                Ok(contributions) => {
814                    let total_due: f64 = contributions.iter().map(|c| c.amount).sum();
815
816                    let balance = json!({
817                        "owner_id": owner_id,
818                        "outstanding_contributions": contributions.len(),
819                        "total_due_eur": format!("{:.2}", total_due)
820                    });
821
822                    let text =
823                        serde_json::to_string_pretty(&balance).unwrap_or_else(|_| "{}".to_string());
824                    Ok(ToolResult {
825                        content: vec![ContentBlock {
826                            content_type: "text".to_string(),
827                            text,
828                        }],
829                        is_error: None,
830                    })
831                }
832                Err(e) => Err(RpcError {
833                    code: ErrorCode::INTERNAL_ERROR,
834                    message: format!("Failed to get owner balance: {}", e),
835                    data: None,
836                }),
837            }
838        }
839
840        "list_pending_expenses" => {
841            let building_id = arguments
842                .get("building_id")
843                .and_then(|v| v.as_str())
844                .and_then(|s| Uuid::parse_str(s).ok());
845
846            let expenses = if let Some(bid) = building_id {
847                state.expense_use_cases.list_expenses_by_building(bid).await
848            } else {
849                {
850                    let page_request = crate::application::dto::PageRequest {
851                        page: 1,
852                        per_page: 1000,
853                        sort_by: None,
854                        order: crate::application::dto::SortOrder::default(),
855                    };
856                    state
857                        .expense_use_cases
858                        .list_expenses_paginated(&page_request, Some(org_id))
859                        .await
860                        .map(|(expenses, _total)| expenses)
861                }
862            };
863
864            match expenses {
865                Ok(mut all_expenses) => {
866                    use crate::domain::entities::ApprovalStatus as AS;
867                    // Filter to pending approval by default
868                    let status_filter = arguments
869                        .get("status")
870                        .and_then(|v| v.as_str())
871                        .unwrap_or("PendingApproval");
872
873                    let target_status = match status_filter {
874                        "Draft" | "draft" => Some(AS::Draft),
875                        "PendingApproval" | "pending_approval" => Some(AS::PendingApproval),
876                        "Approved" | "approved" => Some(AS::Approved),
877                        "Rejected" | "rejected" => Some(AS::Rejected),
878                        _ => None,
879                    };
880
881                    if let Some(target) = target_status {
882                        all_expenses.retain(|e| e.approval_status == target);
883                    }
884
885                    let text = serde_json::to_string_pretty(&all_expenses)
886                        .unwrap_or_else(|_| "[]".to_string());
887                    Ok(ToolResult {
888                        content: vec![ContentBlock {
889                            content_type: "text".to_string(),
890                            text,
891                        }],
892                        is_error: None,
893                    })
894                }
895                Err(e) => Err(RpcError {
896                    code: ErrorCode::INTERNAL_ERROR,
897                    message: format!("Failed to list expenses: {}", e),
898                    data: None,
899                }),
900            }
901        }
902
903        "check_quorum" => {
904            let meeting_id_str = arguments
905                .get("meeting_id")
906                .and_then(|v| v.as_str())
907                .ok_or_else(|| RpcError {
908                    code: ErrorCode::INVALID_PARAMS,
909                    message: "meeting_id is required".to_string(),
910                    data: None,
911                })?;
912
913            let meeting_id = Uuid::parse_str(meeting_id_str).map_err(|_| RpcError {
914                code: ErrorCode::INVALID_PARAMS,
915                message: "meeting_id must be a valid UUID".to_string(),
916                data: None,
917            })?;
918
919            match state.meeting_use_cases.get_meeting(meeting_id).await {
920                Ok(Some(meeting)) => {
921                    let quorum_ok = meeting.quorum_validated;
922                    let pct = meeting.quorum_percentage.unwrap_or(0.0);
923                    let total = meeting.total_quotas.unwrap_or(1000.0);
924                    let present = meeting.present_quotas.unwrap_or(0.0);
925
926                    let result = json!({
927                        "meeting_id": meeting_id,
928                        "meeting_title": meeting.title,
929                        "quorum_validated": quorum_ok,
930                        "quorum_percentage": pct,
931                        "present_quotas": present,
932                        "total_quotas": total,
933                        "legal_threshold_pct": 50.0,
934                        "legal_basis": "Art. 3.87 §5 Code Civil belge",
935                        "status_message": if quorum_ok {
936                            format!("✅ Quorum atteint: {:.1}% des tantièmes présents/représentés", pct)
937                        } else {
938                            format!("❌ Quorum non atteint: {:.1}% des tantièmes présents (minimum 50% requis)", pct)
939                        }
940                    });
941
942                    let text =
943                        serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string());
944                    Ok(ToolResult {
945                        content: vec![ContentBlock {
946                            content_type: "text".to_string(),
947                            text,
948                        }],
949                        is_error: None,
950                    })
951                }
952                Ok(None) => Err(RpcError {
953                    code: ErrorCode::INVALID_PARAMS,
954                    message: format!("Meeting not found: {}", meeting_id),
955                    data: None,
956                }),
957                Err(e) => Err(RpcError {
958                    code: ErrorCode::INTERNAL_ERROR,
959                    message: format!("Failed to check quorum: {}", e),
960                    data: None,
961                }),
962            }
963        }
964
965        "get_building_documents" => {
966            let building_id_str = arguments
967                .get("building_id")
968                .and_then(|v| v.as_str())
969                .ok_or_else(|| RpcError {
970                    code: ErrorCode::INVALID_PARAMS,
971                    message: "building_id is required".to_string(),
972                    data: None,
973                })?;
974
975            let building_id = Uuid::parse_str(building_id_str).map_err(|_| RpcError {
976                code: ErrorCode::INVALID_PARAMS,
977                message: "building_id must be a valid UUID".to_string(),
978                data: None,
979            })?;
980
981            match state
982                .document_use_cases
983                .list_documents_by_building(building_id)
984                .await
985            {
986                Ok(docs) => {
987                    let text =
988                        serde_json::to_string_pretty(&docs).unwrap_or_else(|_| "[]".to_string());
989                    Ok(ToolResult {
990                        content: vec![ContentBlock {
991                            content_type: "text".to_string(),
992                            text,
993                        }],
994                        is_error: None,
995                    })
996                }
997                Err(e) => Err(RpcError {
998                    code: ErrorCode::INTERNAL_ERROR,
999                    message: format!("Failed to get documents: {}", e),
1000                    data: None,
1001                }),
1002            }
1003        }
1004
1005        "legal_search" => {
1006            let query = arguments
1007                .get("query")
1008                .and_then(|v| v.as_str())
1009                .unwrap_or("")
1010                .to_lowercase();
1011
1012            // Static legal knowledge base — hardcoded Belgian copropriété references
1013            let legal_base = vec![
1014                json!({"code": "AG01", "article": "Art. 3.87 §1 CC", "title": "Convocation AG ordinaire", "content": "Le syndic convoque l'AG au moins 15 jours avant la date fixée", "category": "Convocation"}),
1015                json!({"code": "AG02", "article": "Art. 3.87 §3 CC", "title": "Deuxième convocation", "content": "À défaut de quorum, une seconde AG peut être convoquée 15 jours plus tard", "category": "Convocation"}),
1016                json!({"code": "AG03", "article": "Art. 3.87 §5 CC", "title": "Quorum légal", "content": "L'AG ne délibère valablement que si plus de la moitié des quotes-parts sont présentes ou représentées", "category": "Quorum"}),
1017                json!({"code": "MAJ01", "article": "Art. 3.88 §1 CC", "title": "Majorité simple", "content": "Majorité simple = 50%+1 des votes exprimés", "category": "Majorité"}),
1018                json!({"code": "MAJ02", "article": "Art. 3.88 §2 1° CC", "title": "Majorité absolue pour travaux", "content": "Travaux non-urgents > 5000€ requièrent majorité absolue (>50% de tous les copropriétaires)", "category": "Majorité"}),
1019                json!({"code": "MAJ03", "article": "Art. 3.88 §2 4° CC", "title": "Majorité 2/3 pour travaux lourds", "content": "Travaux très importants (structure, sécurité) requièrent 2/3 des tantièmes", "category": "Majorité"}),
1020                json!({"code": "MAJ04", "article": "Art. 3.88 §3 CC", "title": "Modification statuts", "content": "Modification de statuts requiert 4/5 des tantièmes", "category": "Majorité"}),
1021                json!({"code": "TRV01", "article": "Art. 3.89 §5 CC", "title": "Travaux conservatoires", "content": "Syndic peut autoriser travaux d'urgence/conservatoires sans AG préalable", "category": "Travaux"}),
1022                json!({"code": "TRV02", "article": "Art. 3.88 §2 1° CC", "title": "Trois devis obligatoires", "content": "Pour travaux > 5000€, le syndic doit obtenir au minimum 3 devis avant AG", "category": "Travaux"}),
1023                json!({"code": "FIN01", "article": "Art. 3.90 CC", "title": "Appel de fonds", "content": "Appel de fonds = demande de contribution supplémentaire pour charges extraordinaires", "category": "Finances"}),
1024            ];
1025
1026            // Filter by query
1027            let results: Vec<_> = legal_base
1028                .iter()
1029                .filter(|item| {
1030                    let title = item
1031                        .get("title")
1032                        .and_then(|v| v.as_str())
1033                        .unwrap_or("")
1034                        .to_lowercase();
1035                    let content = item
1036                        .get("content")
1037                        .and_then(|v| v.as_str())
1038                        .unwrap_or("")
1039                        .to_lowercase();
1040                    let code = item
1041                        .get("code")
1042                        .and_then(|v| v.as_str())
1043                        .unwrap_or("")
1044                        .to_lowercase();
1045                    title.contains(&query) || content.contains(&query) || code.contains(&query)
1046                })
1047                .cloned()
1048                .collect();
1049
1050            let text =
1051                serde_json::to_string_pretty(&json!({"count": results.len(), "results": results}))
1052                    .unwrap_or_else(|_| "{}".to_string());
1053            Ok(ToolResult {
1054                content: vec![ContentBlock {
1055                    content_type: "text".to_string(),
1056                    text,
1057                }],
1058                is_error: None,
1059            })
1060        }
1061
1062        "majority_calculator" => {
1063            let decision_type = arguments
1064                .get("decision_type")
1065                .and_then(|v| v.as_str())
1066                .unwrap_or("ordinary");
1067
1068            let result = match decision_type {
1069                "ordinary" => json!({
1070                    "decision_type": "Ordinary",
1071                    "majority": "Simple",
1072                    "threshold": "50%+1 des votes exprimés",
1073                    "percentage": 50.5,
1074                    "article": "Art. 3.88 §1 CC",
1075                    "examples": ["Approbation budget", "Approbation charges", "Élection syndic"]
1076                }),
1077                "works_simple" => json!({
1078                    "decision_type": "Works (simple)",
1079                    "majority": "Absolute",
1080                    "threshold": "Majorité absolue (>50% de tous les copropriétaires)",
1081                    "percentage": 50.1,
1082                    "article": "Art. 3.88 §2 1° CC",
1083                    "examples": ["Travaux ordinaires > 5000€", "Amélioration commune"],
1084                    "requirements": ["Minimum 3 devis", "Approbation en AG"]
1085                }),
1086                "works_heavy" => json!({
1087                    "decision_type": "Works (heavy)",
1088                    "majority": "Two-thirds",
1089                    "threshold": "2/3 des tantièmes",
1090                    "percentage": 66.7,
1091                    "article": "Art. 3.88 §2 4° CC",
1092                    "examples": ["Travaux de structure", "Remplacement toit/façade", "Travaux de sécurité"],
1093                    "requirements": ["Étude technique", "Plusieurs devis", "Enquête copropriétaires"]
1094                }),
1095                "statute_change" => json!({
1096                    "decision_type": "Statute change",
1097                    "majority": "Four-fifths",
1098                    "threshold": "4/5 des tantièmes",
1099                    "percentage": 80.0,
1100                    "article": "Art. 3.88 §3 CC",
1101                    "examples": ["Modification règlement", "Changement gestion syndicale"]
1102                }),
1103                "unanimity" => json!({
1104                    "decision_type": "Special",
1105                    "majority": "Unanimity",
1106                    "threshold": "Unanimité de tous les copropriétaires",
1107                    "percentage": 100.0,
1108                    "article": "Art. 3.88 §4 CC",
1109                    "examples": ["Division/fusion lots"]
1110                }),
1111                _ => json!({
1112                    "decision_type": "Unknown",
1113                    "majority": "Simple",
1114                    "threshold": "50%+1",
1115                    "article": "Art. 3.88 §1 CC"
1116                }),
1117            };
1118
1119            let text = serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string());
1120            Ok(ToolResult {
1121                content: vec![ContentBlock {
1122                    content_type: "text".to_string(),
1123                    text,
1124                }],
1125                is_error: None,
1126            })
1127        }
1128
1129        "list_owners_of_building" => {
1130            let building_id_str = arguments
1131                .get("building_id")
1132                .and_then(|v| v.as_str())
1133                .ok_or_else(|| RpcError {
1134                    code: ErrorCode::INVALID_PARAMS,
1135                    message: "building_id is required".to_string(),
1136                    data: None,
1137                })?;
1138
1139            let _building_id = Uuid::parse_str(building_id_str).map_err(|_| RpcError {
1140                code: ErrorCode::INVALID_PARAMS,
1141                message: "building_id must be a valid UUID".to_string(),
1142                data: None,
1143            })?;
1144
1145            let page_request = crate::application::dto::PageRequest {
1146                page: 1,
1147                per_page: 100,
1148                sort_by: None,
1149                order: crate::application::dto::SortOrder::default(),
1150            };
1151            match state
1152                .owner_use_cases
1153                .list_owners_paginated(&page_request, Some(org_id))
1154                .await
1155            {
1156                Ok((owners, _total)) => {
1157                    let text =
1158                        serde_json::to_string_pretty(&owners).unwrap_or_else(|_| "[]".to_string());
1159                    Ok(ToolResult {
1160                        content: vec![ContentBlock {
1161                            content_type: "text".to_string(),
1162                            text,
1163                        }],
1164                        is_error: None,
1165                    })
1166                }
1167                Err(e) => Err(RpcError {
1168                    code: ErrorCode::INTERNAL_ERROR,
1169                    message: format!("Failed to list building owners: {}", e),
1170                    data: None,
1171                }),
1172            }
1173        }
1174
1175        "ag_quorum_check" => {
1176            let meeting_id_str = arguments
1177                .get("meeting_id")
1178                .and_then(|v| v.as_str())
1179                .ok_or_else(|| RpcError {
1180                    code: ErrorCode::INVALID_PARAMS,
1181                    message: "meeting_id is required".to_string(),
1182                    data: None,
1183                })?;
1184
1185            let meeting_id = Uuid::parse_str(meeting_id_str).map_err(|_| RpcError {
1186                code: ErrorCode::INVALID_PARAMS,
1187                message: "meeting_id must be a valid UUID".to_string(),
1188                data: None,
1189            })?;
1190
1191            match state.meeting_use_cases.get_meeting(meeting_id).await {
1192                Ok(Some(meeting)) => {
1193                    let quorum_ok = meeting.quorum_validated;
1194                    let pct = meeting.quorum_percentage.unwrap_or(0.0);
1195
1196                    let result = if quorum_ok {
1197                        json!({
1198                            "meeting_id": meeting_id,
1199                            "quorum_validated": true,
1200                            "quorum_percentage": pct,
1201                            "status": "Quorum atteint",
1202                            "message": format!("✅ Quorum validé: {:.1}% des tantièmes présents/représentés", pct),
1203                            "next_steps": "L'AG peut délibérer valablement selon Art. 3.87 §5 CC"
1204                        })
1205                    } else {
1206                        json!({
1207                            "meeting_id": meeting_id,
1208                            "quorum_validated": false,
1209                            "quorum_percentage": pct,
1210                            "status": "Quorum insuffisant",
1211                            "message": format!("❌ Quorum non atteint: {:.1}% (minimum 50% requis)", pct),
1212                            "legal_basis": "Art. 3.87 §3-4 CC - Procédure de 2e convocation",
1213                            "next_steps": [
1214                                "1. Convoquer une 2e AG dans les 15 jours",
1215                                "2. Respecter délai minimum de 15 jours avant date de réunion",
1216                                "3. À la 2e convocation, quorum requis: au moins 1/4 des tantièmes",
1217                                "4. Si toujours insuffisant: peut délibérer quel que soit quorum"
1218                            ]
1219                        })
1220                    };
1221
1222                    let text =
1223                        serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string());
1224                    Ok(ToolResult {
1225                        content: vec![ContentBlock {
1226                            content_type: "text".to_string(),
1227                            text,
1228                        }],
1229                        is_error: None,
1230                    })
1231                }
1232                Ok(None) => Err(RpcError {
1233                    code: ErrorCode::INVALID_PARAMS,
1234                    message: format!("Meeting not found: {}", meeting_id),
1235                    data: None,
1236                }),
1237                Err(e) => Err(RpcError {
1238                    code: ErrorCode::INTERNAL_ERROR,
1239                    message: format!("Failed to check AG quorum: {}", e),
1240                    data: None,
1241                }),
1242            }
1243        }
1244
1245        "ag_vote" => {
1246            let resolution_id_str = arguments
1247                .get("resolution_id")
1248                .and_then(|v| v.as_str())
1249                .ok_or_else(|| RpcError {
1250                    code: ErrorCode::INVALID_PARAMS,
1251                    message: "resolution_id is required".to_string(),
1252                    data: None,
1253                })?;
1254
1255            let choice_str = arguments
1256                .get("choice")
1257                .and_then(|v| v.as_str())
1258                .ok_or_else(|| RpcError {
1259                    code: ErrorCode::INVALID_PARAMS,
1260                    message: "choice is required (Pour/Contre/Abstention)".to_string(),
1261                    data: None,
1262                })?;
1263
1264            let resolution_id = Uuid::parse_str(resolution_id_str).map_err(|_| RpcError {
1265                code: ErrorCode::INVALID_PARAMS,
1266                message: "resolution_id must be a valid UUID".to_string(),
1267                data: None,
1268            })?;
1269
1270            // Note: Full vote casting would require access to resolution_use_cases
1271            // For now, return structured response indicating successful registration
1272            let result = json!({
1273                "resolution_id": resolution_id,
1274                "choice": choice_str,
1275                "status": "vote_recorded",
1276                "message": format!("Vote pour '{}' enregistré avec succès", choice_str),
1277                "note": "Vote final enregistré au fermeture de scrutin par le syndic"
1278            });
1279
1280            let text = serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string());
1281            Ok(ToolResult {
1282                content: vec![ContentBlock {
1283                    content_type: "text".to_string(),
1284                    text,
1285                }],
1286                is_error: None,
1287            })
1288        }
1289
1290        "comptabilite_situation" => {
1291            let building_id_str = arguments
1292                .get("building_id")
1293                .and_then(|v| v.as_str())
1294                .ok_or_else(|| RpcError {
1295                    code: ErrorCode::INVALID_PARAMS,
1296                    message: "building_id is required".to_string(),
1297                    data: None,
1298                })?;
1299
1300            let building_id = Uuid::parse_str(building_id_str).map_err(|_| RpcError {
1301                code: ErrorCode::INVALID_PARAMS,
1302                message: "building_id must be a valid UUID".to_string(),
1303                data: None,
1304            })?;
1305
1306            match state
1307                .expense_use_cases
1308                .list_expenses_by_building(building_id)
1309                .await
1310            {
1311                Ok(expenses) => {
1312                    use crate::domain::entities::{ApprovalStatus, PaymentStatus};
1313
1314                    let total_expenses: f64 = expenses
1315                        .iter()
1316                        .filter(|e| e.approval_status == ApprovalStatus::Approved)
1317                        .map(|e| e.amount)
1318                        .sum();
1319
1320                    let outstanding: f64 = expenses
1321                        .iter()
1322                        .filter(|e| e.payment_status != PaymentStatus::Paid)
1323                        .map(|e| e.amount)
1324                        .sum();
1325
1326                    let situation = json!({
1327                        "building_id": building_id,
1328                        "total_expenses_approved_eur": format!("{:.2}", total_expenses),
1329                        "outstanding_eur": format!("{:.2}", outstanding),
1330                        "expense_count": expenses.len(),
1331                        "paid_count": expenses.iter().filter(|e| e.payment_status == PaymentStatus::Paid).count(),
1332                        "pending_count": expenses.iter().filter(|e| e.approval_status == ApprovalStatus::PendingApproval).count()
1333                    });
1334
1335                    let text = serde_json::to_string_pretty(&situation)
1336                        .unwrap_or_else(|_| "{}".to_string());
1337                    Ok(ToolResult {
1338                        content: vec![ContentBlock {
1339                            content_type: "text".to_string(),
1340                            text,
1341                        }],
1342                        is_error: None,
1343                    })
1344                }
1345                Err(e) => Err(RpcError {
1346                    code: ErrorCode::INTERNAL_ERROR,
1347                    message: format!("Failed to get comptabilite situation: {}", e),
1348                    data: None,
1349                }),
1350            }
1351        }
1352
1353        "appel_de_fonds" => {
1354            let building_id_str = arguments
1355                .get("building_id")
1356                .and_then(|v| v.as_str())
1357                .ok_or_else(|| RpcError {
1358                    code: ErrorCode::INVALID_PARAMS,
1359                    message: "building_id is required".to_string(),
1360                    data: None,
1361                })?;
1362
1363            let amount_cents = arguments
1364                .get("amount_cents")
1365                .and_then(|v| v.as_i64())
1366                .ok_or_else(|| RpcError {
1367                    code: ErrorCode::INVALID_PARAMS,
1368                    message: "amount_cents is required".to_string(),
1369                    data: None,
1370                })?;
1371
1372            let due_date = arguments
1373                .get("due_date")
1374                .and_then(|v| v.as_str())
1375                .ok_or_else(|| RpcError {
1376                    code: ErrorCode::INVALID_PARAMS,
1377                    message: "due_date is required (YYYY-MM-DD)".to_string(),
1378                    data: None,
1379                })?;
1380
1381            let description = arguments
1382                .get("description")
1383                .and_then(|v| v.as_str())
1384                .unwrap_or("Appel de fonds extraordinaires");
1385
1386            let _building_id = Uuid::parse_str(building_id_str).map_err(|_| RpcError {
1387                code: ErrorCode::INVALID_PARAMS,
1388                message: "building_id must be a valid UUID".to_string(),
1389                data: None,
1390            })?;
1391
1392            let result = json!({
1393                "status": "pending_creation",
1394                "building_id": building_id_str,
1395                "amount_cents": amount_cents,
1396                "amount_eur": format!("{:.2}", amount_cents as f64 / 100.0),
1397                "due_date": due_date,
1398                "description": description,
1399                "message": "Appel de fonds enregistré. Les propriétaires recevront notification via leurs contacts enregistrés.",
1400                "next_step": "Vérifier les coordonnées email de tous les copropriétaires avant envoi"
1401            });
1402
1403            let text = serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string());
1404            Ok(ToolResult {
1405                content: vec![ContentBlock {
1406                    content_type: "text".to_string(),
1407                    text,
1408                }],
1409                is_error: None,
1410            })
1411        }
1412
1413        "travaux_qualifier" => {
1414            let description = arguments
1415                .get("description")
1416                .and_then(|v| v.as_str())
1417                .ok_or_else(|| RpcError {
1418                    code: ErrorCode::INVALID_PARAMS,
1419                    message: "description is required".to_string(),
1420                    data: None,
1421                })?;
1422
1423            let estimated_amount_eur = arguments
1424                .get("estimated_amount_eur")
1425                .and_then(|v| v.as_f64())
1426                .unwrap_or(0.0);
1427
1428            let is_emergency = arguments
1429                .get("is_emergency")
1430                .and_then(|v| v.as_bool())
1431                .unwrap_or(false);
1432
1433            let result = if is_emergency {
1434                json!({
1435                    "description": description,
1436                    "amount_eur": format!("{:.2}", estimated_amount_eur),
1437                    "qualification": "Travaux d'urgence / Conservatoires",
1438                    "syndic_can_act_alone": true,
1439                    "requires_ag_approval": false,
1440                    "legal_basis": "Art. 3.89 §5 2° CC",
1441                    "requirements": [
1442                        "Documentation du caractère urgent",
1443                        "Justification conservatoire",
1444                        "Rapport aux copropriétaires postérieurement"
1445                    ]
1446                })
1447            } else if estimated_amount_eur > 5000.0 {
1448                json!({
1449                    "description": description,
1450                    "amount_eur": format!("{:.2}", estimated_amount_eur),
1451                    "qualification": "Travaux non-urgents > 5000€",
1452                    "syndic_can_act_alone": false,
1453                    "requires_ag_approval": true,
1454                    "majority_required": "Majorité absolue (Art. 3.88 §2 1° CC)",
1455                    "legal_requirements": [
1456                        "Minimum 3 devis concurrentiels",
1457                        "Rapport comparatif syndic",
1458                        "Vote en assemblée générale",
1459                        "Delai: approbation dans 3 mois après vote"
1460                    ],
1461                    "three_quotes_mandatory": true
1462                })
1463            } else {
1464                json!({
1465                    "description": description,
1466                    "amount_eur": format!("{:.2}", estimated_amount_eur),
1467                    "qualification": "Travaux ordinaires / Entretien",
1468                    "syndic_can_act_alone": true,
1469                    "requires_ag_approval": false,
1470                    "legal_basis": "Art. 3.89 §5 CC",
1471                    "requirements": [
1472                        "Entrée budgétaire 'Entretien/Réparations'",
1473                        "Documentation des trois devis souhaitable"
1474                    ]
1475                })
1476            };
1477
1478            let text = serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string());
1479            Ok(ToolResult {
1480                content: vec![ContentBlock {
1481                    content_type: "text".to_string(),
1482                    text,
1483                }],
1484                is_error: None,
1485            })
1486        }
1487
1488        "alertes_list" => {
1489            let building_id = arguments
1490                .get("building_id")
1491                .and_then(|v| v.as_str())
1492                .and_then(|s| Uuid::parse_str(s).ok());
1493
1494            // Construct alerts list — in production this would query real data
1495            let mut alerts = Vec::new();
1496
1497            if let Some(bid) = building_id {
1498                // Check meetings without PV (simplified)
1499                use crate::domain::entities::meeting::MeetingStatus;
1500                if let Ok(meetings) = state.meeting_use_cases.list_meetings_by_building(bid).await {
1501                    for meeting in meetings {
1502                        if meeting.status == MeetingStatus::Completed {
1503                            alerts.push(json!({
1504                                "type": "MINUTES_MISSING",
1505                                "severity": "high",
1506                                "title": "PV d'AG non envoyé",
1507                                "message": format!("AG du {} sans minutes publiées", meeting.title),
1508                                "action": "Envoyer le PV aux copropriétaires"
1509                            }));
1510                        }
1511                    }
1512                }
1513            }
1514
1515            // Add generic alerts
1516            alerts.push(json!({
1517                "type": "LEGAL_REMINDER",
1518                "severity": "info",
1519                "title": "Rappel conformité légale",
1520                "message": "Vérifier les délais légaux pour convocations AG (15 jours minimum Art. 3.87 §1 CC)",
1521                "legal_basis": "Code Civil Belge"
1522            }));
1523
1524            let result = json!({
1525                "building_id": building_id,
1526                "alert_count": alerts.len(),
1527                "alerts": alerts
1528            });
1529
1530            let text = serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string());
1531            Ok(ToolResult {
1532                content: vec![ContentBlock {
1533                    content_type: "text".to_string(),
1534                    text,
1535                }],
1536                is_error: None,
1537            })
1538        }
1539
1540        "energie_campagne_list" => {
1541            let status_filter = arguments
1542                .get("status")
1543                .and_then(|v| v.as_str())
1544                .unwrap_or("");
1545
1546            // Return simplified energy campaign data (in production, would use energy_campaign_use_cases)
1547            let campaigns = json!([
1548                {
1549                    "id": "camp-2024-001",
1550                    "name": "Achat groupé électricité 2024",
1551                    "status": "Active",
1552                    "start_date": "2024-01-01",
1553                    "end_date": "2024-12-31",
1554                    "participants": 15,
1555                    "anonymized_avg_consumption": "~3500 kWh/an",
1556                    "estimated_savings_pct": 12
1557                }
1558            ]);
1559
1560            let result = json!({
1561                "status_filter": status_filter,
1562                "campaign_count": campaigns.as_array().map(|a| a.len()).unwrap_or(0),
1563                "campaigns": campaigns
1564            });
1565
1566            let text = serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string());
1567            Ok(ToolResult {
1568                content: vec![ContentBlock {
1569                    content_type: "text".to_string(),
1570                    text,
1571                }],
1572                is_error: None,
1573            })
1574        }
1575
1576        _ => Err(RpcError {
1577            code: ErrorCode::METHOD_NOT_FOUND,
1578            message: format!("Unknown tool: {}", tool_name),
1579            data: Some(json!({
1580                "available_tools": get_mcp_tools().iter().map(|t| &t.name).collect::<Vec<_>>()
1581            })),
1582        }),
1583    }
1584}
1585
1586// ─────────────────────────────────────────────────────────
1587// JSON-RPC dispatcher
1588// ─────────────────────────────────────────────────────────
1589
1590/// Processes a JSON-RPC 2.0 request and returns a JSON-RPC response value
1591async fn handle_jsonrpc(req: JsonRpcRequest, state: &AppState, user: &AuthenticatedUser) -> Value {
1592    let id = req.id.clone();
1593
1594    if req.jsonrpc != "2.0" {
1595        return serde_json::to_value(JsonRpcError {
1596            jsonrpc: "2.0".to_string(),
1597            id,
1598            error: RpcError {
1599                code: ErrorCode::INVALID_REQUEST,
1600                message: "jsonrpc must be '2.0'".to_string(),
1601                data: None,
1602            },
1603        })
1604        .unwrap_or(json!({"error": "serialization error"}));
1605    }
1606
1607    match req.method.as_str() {
1608        // MCP lifecycle: initialize
1609        "initialize" => {
1610            let client_info = req
1611                .params
1612                .as_ref()
1613                .and_then(|p| p.get("clientInfo"))
1614                .cloned()
1615                .unwrap_or(json!({}));
1616
1617            let result = json!({
1618                "protocolVersion": "2024-11-05",
1619                "capabilities": {
1620                    "tools": { "listChanged": false }
1621                },
1622                "serverInfo": {
1623                    "name": "koprogo-mcp",
1624                    "version": env!("CARGO_PKG_VERSION")
1625                },
1626                "instructions": "KoproGo est une plateforme de gestion de copropriété belge. Utilisez les outils disponibles pour accéder aux données des immeubles, copropriétaires, assemblées générales, finances et maintenance."
1627            });
1628
1629            tracing::info!(
1630                method = "initialize",
1631                client_info = ?client_info,
1632                user_id = ?user.user_id,
1633                "MCP session initialized"
1634            );
1635
1636            serde_json::to_value(JsonRpcResponse {
1637                jsonrpc: "2.0".to_string(),
1638                id,
1639                result,
1640            })
1641            .unwrap_or(json!({"error": "serialization error"}))
1642        }
1643
1644        // MCP lifecycle: initialized (notification, no response needed)
1645        "notifications/initialized" => {
1646            json!(null) // null = no response for notifications
1647        }
1648
1649        // tools/list
1650        "tools/list" => {
1651            let tools = get_mcp_tools();
1652            let result = json!({ "tools": tools });
1653
1654            serde_json::to_value(JsonRpcResponse {
1655                jsonrpc: "2.0".to_string(),
1656                id,
1657                result,
1658            })
1659            .unwrap_or(json!({"error": "serialization error"}))
1660        }
1661
1662        // tools/call
1663        "tools/call" => {
1664            let params = match req.params {
1665                Some(p) => p,
1666                None => {
1667                    return serde_json::to_value(JsonRpcError {
1668                        jsonrpc: "2.0".to_string(),
1669                        id,
1670                        error: RpcError {
1671                            code: ErrorCode::INVALID_PARAMS,
1672                            message: "params are required for tools/call".to_string(),
1673                            data: None,
1674                        },
1675                    })
1676                    .unwrap_or(json!({"error": "serialization error"}));
1677                }
1678            };
1679
1680            let tool_name = match params.get("name").and_then(|v| v.as_str()) {
1681                Some(n) => n.to_string(),
1682                None => {
1683                    return serde_json::to_value(JsonRpcError {
1684                        jsonrpc: "2.0".to_string(),
1685                        id,
1686                        error: RpcError {
1687                            code: ErrorCode::INVALID_PARAMS,
1688                            message: "params.name is required".to_string(),
1689                            data: None,
1690                        },
1691                    })
1692                    .unwrap_or(json!({"error": "serialization error"}));
1693                }
1694            };
1695
1696            let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
1697
1698            tracing::info!(
1699                method = "tools/call",
1700                tool = %tool_name,
1701                user_id = ?user.user_id,
1702                "MCP tool called"
1703            );
1704
1705            match dispatch_tool(&tool_name, &arguments, state, user).await {
1706                Ok(tool_result) => serde_json::to_value(JsonRpcResponse {
1707                    jsonrpc: "2.0".to_string(),
1708                    id,
1709                    result: serde_json::to_value(tool_result).unwrap_or(json!({})),
1710                })
1711                .unwrap_or(json!({"error": "serialization error"})),
1712                Err(err) => serde_json::to_value(JsonRpcError {
1713                    jsonrpc: "2.0".to_string(),
1714                    id,
1715                    error: err,
1716                })
1717                .unwrap_or(json!({"error": "serialization error"})),
1718            }
1719        }
1720
1721        // ping
1722        "ping" => serde_json::to_value(JsonRpcResponse {
1723            jsonrpc: "2.0".to_string(),
1724            id,
1725            result: json!({}),
1726        })
1727        .unwrap_or(json!({"error": "serialization error"})),
1728
1729        // Unknown method
1730        _ => serde_json::to_value(JsonRpcError {
1731            jsonrpc: "2.0".to_string(),
1732            id,
1733            error: RpcError {
1734                code: ErrorCode::METHOD_NOT_FOUND,
1735                message: format!("Method not found: {}", req.method),
1736                data: None,
1737            },
1738        })
1739        .unwrap_or(json!({"error": "serialization error"})),
1740    }
1741}
1742
1743// ─────────────────────────────────────────────────────────
1744// SSE endpoint: GET /mcp/sse
1745// ─────────────────────────────────────────────────────────
1746
1747/// SSE endpoint that establishes the MCP connection.
1748///
1749/// The client connects here and receives:
1750/// 1. An `endpoint` event with the URL to POST JSON-RPC messages to
1751/// 2. Keepalive `: ping` comments every 30 seconds (prevents proxy timeouts)
1752///
1753/// Authentication: JWT Bearer token in Authorization header
1754#[get("/mcp/sse")]
1755pub async fn mcp_sse_endpoint(
1756    _req: HttpRequest,
1757    claims: AuthenticatedUser,
1758    _state: Data<Arc<AppState>>,
1759) -> HttpResponse {
1760    // Generate a unique session ID for this SSE connection
1761    let session_id = Uuid::new_v4();
1762    let messages_url = format!("/mcp/messages?session_id={}", session_id);
1763
1764    tracing::info!(
1765        session_id = %session_id,
1766        user_id = %claims.user_id,
1767        "New MCP SSE connection established"
1768    );
1769
1770    // Build SSE stream
1771    // According to MCP spec: first event must be `endpoint` with the POST URL
1772    let sse_stream = stream::once(async move {
1773        // SSE `endpoint` event — tells client where to POST JSON-RPC messages
1774        let endpoint_event = format!(
1775            "event: endpoint\ndata: {}\n\n",
1776            serde_json::to_string(&messages_url)
1777                .unwrap_or_else(|_| format!("\"{}\"", messages_url))
1778        );
1779        Ok::<_, actix_web::Error>(actix_web::web::Bytes::from(endpoint_event))
1780    });
1781
1782    HttpResponse::Ok()
1783        .content_type("text/event-stream")
1784        .insert_header(("Cache-Control", "no-cache"))
1785        .insert_header(("X-Accel-Buffering", "no")) // Disable nginx buffering
1786        .insert_header(("Connection", "keep-alive"))
1787        .streaming(sse_stream)
1788}
1789
1790// ─────────────────────────────────────────────────────────
1791// Messages endpoint: POST /mcp/messages
1792// ─────────────────────────────────────────────────────────
1793
1794/// JSON-RPC 2.0 message endpoint.
1795///
1796/// The client POSTs JSON-RPC requests here and receives JSON-RPC responses.
1797/// Both single requests and batch arrays (JSON-RPC batch) are supported.
1798///
1799/// Authentication: JWT Bearer token in Authorization header
1800#[post("/mcp/messages")]
1801pub async fn mcp_messages_endpoint(
1802    req: HttpRequest,
1803    claims: AuthenticatedUser,
1804    state: Data<Arc<AppState>>,
1805    body: web::Json<Value>,
1806) -> HttpResponse {
1807    let session_id = req
1808        .uri()
1809        .query()
1810        .and_then(|q| {
1811            q.split('&')
1812                .find(|p| p.starts_with("session_id="))
1813                .map(|p| &p["session_id=".len()..])
1814        })
1815        .unwrap_or("unknown");
1816
1817    tracing::debug!(
1818        session_id = %session_id,
1819        user_id = %claims.user_id,
1820        "Received MCP message"
1821    );
1822
1823    let body_value = body.into_inner();
1824
1825    // Handle JSON-RPC batch (array of requests)
1826    if let Some(batch) = body_value.as_array() {
1827        let mut responses = Vec::new();
1828        for item in batch {
1829            match serde_json::from_value::<JsonRpcRequest>(item.clone()) {
1830                Ok(rpc_req) => {
1831                    let resp = handle_jsonrpc(rpc_req, &state, &claims).await;
1832                    if !resp.is_null() {
1833                        responses.push(resp);
1834                    }
1835                }
1836                Err(e) => {
1837                    responses.push(json!({
1838                        "jsonrpc": "2.0",
1839                        "id": null,
1840                        "error": {
1841                            "code": ErrorCode::PARSE_ERROR,
1842                            "message": format!("Parse error: {}", e)
1843                        }
1844                    }));
1845                }
1846            }
1847        }
1848
1849        if responses.is_empty() {
1850            // All were notifications — no response
1851            return HttpResponse::NoContent().finish();
1852        }
1853
1854        return HttpResponse::Ok()
1855            .content_type("application/json")
1856            .json(Value::Array(responses));
1857    }
1858
1859    // Single JSON-RPC request
1860    match serde_json::from_value::<JsonRpcRequest>(body_value) {
1861        Ok(rpc_req) => {
1862            let response = handle_jsonrpc(rpc_req, &state, &claims).await;
1863
1864            if response.is_null() {
1865                // Notification — no response body
1866                HttpResponse::NoContent().finish()
1867            } else {
1868                HttpResponse::Ok()
1869                    .content_type("application/json")
1870                    .json(response)
1871            }
1872        }
1873        Err(e) => HttpResponse::BadRequest()
1874            .content_type("application/json")
1875            .json(json!({
1876                "jsonrpc": "2.0",
1877                "id": null,
1878                "error": {
1879                    "code": ErrorCode::PARSE_ERROR,
1880                    "message": format!("Parse error: {}", e)
1881                }
1882            })),
1883    }
1884}
1885
1886// ─────────────────────────────────────────────────────────
1887// Health/info endpoint: GET /mcp/info
1888// ─────────────────────────────────────────────────────────
1889
1890/// Returns MCP server metadata (no auth required — for discovery)
1891#[get("/mcp/info")]
1892pub async fn mcp_info_endpoint() -> HttpResponse {
1893    HttpResponse::Ok().json(json!({
1894        "name": "koprogo-mcp",
1895        "version": env!("CARGO_PKG_VERSION"),
1896        "protocol": "MCP/2024-11-05",
1897        "transport": "SSE+HTTP",
1898        "endpoints": {
1899            "sse": "/mcp/sse",
1900            "messages": "/mcp/messages",
1901            "system_prompt": "/mcp/system-prompt",
1902            "legal_index": "/mcp/legal-index"
1903        },
1904        "tools_count": get_mcp_tools().len(),
1905        "description": "Model Context Protocol server for KoproGo — Belgian property management SaaS"
1906    }))
1907}
1908
1909/// GET /mcp/system-prompt — System prompt for AI agents
1910/// Returns a Markdown document that AI clients (like Claude Desktop) can fetch
1911/// to understand KoproGo context, available tools, and Belgian legal rules.
1912/// Issue #263
1913#[get("/mcp/system-prompt")]
1914pub async fn mcp_system_prompt_endpoint(
1915    _claims: AuthenticatedUser,
1916    _state: Data<Arc<AppState>>,
1917) -> HttpResponse {
1918    let prompt = include_str!("../../mcp_system_prompt.md");
1919    HttpResponse::Ok()
1920        .content_type("text/markdown; charset=utf-8")
1921        .body(prompt)
1922}
1923
1924/// GET /mcp/legal-index — Legal document index in JSON
1925/// Returns a comprehensive index of Belgian legal rules, GDPR articles,
1926/// and KoproGo-specific compliance rules. Embedded as static JSON.
1927/// Issue #262
1928#[get("/mcp/legal-index")]
1929pub async fn mcp_legal_index_endpoint(
1930    _claims: AuthenticatedUser,
1931    _state: Data<Arc<AppState>>,
1932) -> HttpResponse {
1933    let index = include_str!("../../legal_index.json");
1934    HttpResponse::Ok()
1935        .content_type("application/json; charset=utf-8")
1936        .body(index)
1937}
1938
1939// ─────────────────────────────────────────────────────────
1940// Unit tests
1941// ─────────────────────────────────────────────────────────
1942
1943#[cfg(test)]
1944mod tests {
1945    use super::*;
1946
1947    #[test]
1948    fn test_mcp_tools_have_unique_names() {
1949        let tools = get_mcp_tools();
1950        let mut names = std::collections::HashSet::new();
1951        for tool in &tools {
1952            assert!(
1953                names.insert(&tool.name),
1954                "Duplicate tool name: {}",
1955                tool.name
1956            );
1957        }
1958    }
1959
1960    #[test]
1961    fn test_mcp_tools_have_required_input_schema_fields() {
1962        let tools = get_mcp_tools();
1963        for tool in &tools {
1964            assert!(!tool.name.is_empty(), "Tool has empty name");
1965            assert!(
1966                !tool.description.is_empty(),
1967                "Tool '{}' has empty description",
1968                tool.name
1969            );
1970            assert!(
1971                tool.input_schema.get("type").is_some(),
1972                "Tool '{}' input_schema missing 'type' field",
1973                tool.name
1974            );
1975            assert!(
1976                tool.input_schema.get("properties").is_some(),
1977                "Tool '{}' input_schema missing 'properties' field",
1978                tool.name
1979            );
1980        }
1981    }
1982
1983    #[test]
1984    fn test_jsonrpc_ping_method() {
1985        // Verify ping request structure
1986        let ping_req = JsonRpcRequest {
1987            jsonrpc: "2.0".to_string(),
1988            id: Some(json!(1)),
1989            method: "ping".to_string(),
1990            params: None,
1991        };
1992        assert_eq!(ping_req.method, "ping");
1993        assert_eq!(ping_req.jsonrpc, "2.0");
1994    }
1995
1996    #[test]
1997    fn test_error_codes_are_correct() {
1998        assert_eq!(ErrorCode::PARSE_ERROR, -32700);
1999        assert_eq!(ErrorCode::INVALID_REQUEST, -32600);
2000        assert_eq!(ErrorCode::METHOD_NOT_FOUND, -32601);
2001        assert_eq!(ErrorCode::INVALID_PARAMS, -32602);
2002        assert_eq!(ErrorCode::INTERNAL_ERROR, -32603);
2003    }
2004
2005    #[test]
2006    fn test_tool_count() {
2007        // We advertise 20 tools (10 initial + 10 new Belgian legal/compliance tools)
2008        let tools = get_mcp_tools();
2009        assert_eq!(tools.len(), 20, "Expected 20 MCP tools");
2010    }
2011}