Skip to main content

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