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: f64 = expenses.iter().map(|e| e.amount).sum();
716                    let pending_count = expenses
717                        .iter()
718                        .filter(|e| e.approval_status == ApprovalStatus::PendingApproval)
719                        .count();
720                    let overdue_count = expenses
721                        .iter()
722                        .filter(|e| e.payment_status == PaymentStatus::Overdue)
723                        .count();
724
725                    let summary = json!({
726                        "building_id": building_id,
727                        "total_expenses_eur": format!("{:.2}", total_expenses),
728                        "pending_approval_count": pending_count,
729                        "overdue_count": overdue_count,
730                        "total_expense_count": expenses.len()
731                    });
732
733                    let text =
734                        serde_json::to_string_pretty(&summary).unwrap_or_else(|_| "{}".to_string());
735                    Ok(ToolResult {
736                        content: vec![ContentBlock {
737                            content_type: "text".to_string(),
738                            text,
739                        }],
740                        is_error: None,
741                    })
742                }
743                Err(e) => Err(RpcError {
744                    code: ErrorCode::INTERNAL_ERROR,
745                    message: format!("Failed to get financial summary: {}", e),
746                    data: None,
747                }),
748            }
749        }
750
751        "list_tickets" => {
752            let building_id_str = arguments
753                .get("building_id")
754                .and_then(|v| v.as_str())
755                .ok_or_else(|| RpcError {
756                    code: ErrorCode::INVALID_PARAMS,
757                    message: "building_id is required".to_string(),
758                    data: None,
759                })?;
760
761            let building_id = Uuid::parse_str(building_id_str).map_err(|_| RpcError {
762                code: ErrorCode::INVALID_PARAMS,
763                message: "building_id must be a valid UUID".to_string(),
764                data: None,
765            })?;
766
767            match state
768                .ticket_use_cases
769                .list_tickets_by_building(building_id)
770                .await
771            {
772                Ok(tickets) => {
773                    let text =
774                        serde_json::to_string_pretty(&tickets).unwrap_or_else(|_| "[]".to_string());
775                    Ok(ToolResult {
776                        content: vec![ContentBlock {
777                            content_type: "text".to_string(),
778                            text,
779                        }],
780                        is_error: None,
781                    })
782                }
783                Err(e) => Err(RpcError {
784                    code: ErrorCode::INTERNAL_ERROR,
785                    message: format!("Failed to list tickets: {}", e),
786                    data: None,
787                }),
788            }
789        }
790
791        "get_owner_balance" => {
792            let owner_id_str = arguments
793                .get("owner_id")
794                .and_then(|v| v.as_str())
795                .ok_or_else(|| RpcError {
796                    code: ErrorCode::INVALID_PARAMS,
797                    message: "owner_id is required".to_string(),
798                    data: None,
799                })?;
800
801            let owner_id = Uuid::parse_str(owner_id_str).map_err(|_| RpcError {
802                code: ErrorCode::INVALID_PARAMS,
803                message: "owner_id must be a valid UUID".to_string(),
804                data: None,
805            })?;
806
807            match state
808                .owner_contribution_use_cases
809                .get_outstanding_contributions(owner_id)
810                .await
811            {
812                Ok(contributions) => {
813                    let total_due: f64 = contributions.iter().map(|c| c.amount).sum();
814
815                    let balance = json!({
816                        "owner_id": owner_id,
817                        "outstanding_contributions": contributions.len(),
818                        "total_due_eur": format!("{:.2}", total_due)
819                    });
820
821                    let text =
822                        serde_json::to_string_pretty(&balance).unwrap_or_else(|_| "{}".to_string());
823                    Ok(ToolResult {
824                        content: vec![ContentBlock {
825                            content_type: "text".to_string(),
826                            text,
827                        }],
828                        is_error: None,
829                    })
830                }
831                Err(e) => Err(RpcError {
832                    code: ErrorCode::INTERNAL_ERROR,
833                    message: format!("Failed to get owner balance: {}", e),
834                    data: None,
835                }),
836            }
837        }
838
839        "list_pending_expenses" => {
840            let building_id = arguments
841                .get("building_id")
842                .and_then(|v| v.as_str())
843                .and_then(|s| Uuid::parse_str(s).ok());
844
845            let expenses = if let Some(bid) = building_id {
846                state.expense_use_cases.list_expenses_by_building(bid).await
847            } else {
848                {
849                    let page_request = crate::application::dto::PageRequest {
850                        page: 1,
851                        per_page: 1000,
852                        sort_by: None,
853                        order: crate::application::dto::SortOrder::default(),
854                    };
855                    state
856                        .expense_use_cases
857                        .list_expenses_paginated(&page_request, Some(org_id))
858                        .await
859                        .map(|(expenses, _total)| expenses)
860                }
861            };
862
863            match expenses {
864                Ok(mut all_expenses) => {
865                    use crate::domain::entities::ApprovalStatus as AS;
866                    // Filter to pending approval by default
867                    let status_filter = arguments
868                        .get("status")
869                        .and_then(|v| v.as_str())
870                        .unwrap_or("PendingApproval");
871
872                    let target_status = match status_filter {
873                        "Draft" | "draft" => Some(AS::Draft),
874                        "PendingApproval" | "pending_approval" => Some(AS::PendingApproval),
875                        "Approved" | "approved" => Some(AS::Approved),
876                        "Rejected" | "rejected" => Some(AS::Rejected),
877                        _ => None,
878                    };
879
880                    if let Some(target) = target_status {
881                        all_expenses.retain(|e| e.approval_status == target);
882                    }
883
884                    let text = serde_json::to_string_pretty(&all_expenses)
885                        .unwrap_or_else(|_| "[]".to_string());
886                    Ok(ToolResult {
887                        content: vec![ContentBlock {
888                            content_type: "text".to_string(),
889                            text,
890                        }],
891                        is_error: None,
892                    })
893                }
894                Err(e) => Err(RpcError {
895                    code: ErrorCode::INTERNAL_ERROR,
896                    message: format!("Failed to list expenses: {}", e),
897                    data: None,
898                }),
899            }
900        }
901
902        "check_quorum" => {
903            let meeting_id_str = arguments
904                .get("meeting_id")
905                .and_then(|v| v.as_str())
906                .ok_or_else(|| RpcError {
907                    code: ErrorCode::INVALID_PARAMS,
908                    message: "meeting_id is required".to_string(),
909                    data: None,
910                })?;
911
912            let meeting_id = Uuid::parse_str(meeting_id_str).map_err(|_| RpcError {
913                code: ErrorCode::INVALID_PARAMS,
914                message: "meeting_id must be a valid UUID".to_string(),
915                data: None,
916            })?;
917
918            match state.meeting_use_cases.get_meeting(meeting_id).await {
919                Ok(Some(meeting)) => {
920                    let quorum_ok = meeting.quorum_validated;
921                    let pct = meeting.quorum_percentage.unwrap_or(0.0);
922                    let total = meeting.total_quotas.unwrap_or(1000.0);
923                    let present = meeting.present_quotas.unwrap_or(0.0);
924
925                    let result = json!({
926                        "meeting_id": meeting_id,
927                        "meeting_title": meeting.title,
928                        "quorum_validated": quorum_ok,
929                        "quorum_percentage": pct,
930                        "present_quotas": present,
931                        "total_quotas": total,
932                        "legal_threshold_pct": 50.0,
933                        "legal_basis": "Art. 3.87 §5 Code Civil belge",
934                        "status_message": if quorum_ok {
935                            format!("✅ Quorum atteint: {:.1}% des tantièmes présents/représentés", pct)
936                        } else {
937                            format!("❌ Quorum non atteint: {:.1}% des tantièmes présents (minimum 50% requis)", pct)
938                        }
939                    });
940
941                    let text =
942                        serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string());
943                    Ok(ToolResult {
944                        content: vec![ContentBlock {
945                            content_type: "text".to_string(),
946                            text,
947                        }],
948                        is_error: None,
949                    })
950                }
951                Ok(None) => Err(RpcError {
952                    code: ErrorCode::INVALID_PARAMS,
953                    message: format!("Meeting not found: {}", meeting_id),
954                    data: None,
955                }),
956                Err(e) => Err(RpcError {
957                    code: ErrorCode::INTERNAL_ERROR,
958                    message: format!("Failed to check quorum: {}", e),
959                    data: None,
960                }),
961            }
962        }
963
964        "get_building_documents" => {
965            let building_id_str = arguments
966                .get("building_id")
967                .and_then(|v| v.as_str())
968                .ok_or_else(|| RpcError {
969                    code: ErrorCode::INVALID_PARAMS,
970                    message: "building_id is required".to_string(),
971                    data: None,
972                })?;
973
974            let building_id = Uuid::parse_str(building_id_str).map_err(|_| RpcError {
975                code: ErrorCode::INVALID_PARAMS,
976                message: "building_id must be a valid UUID".to_string(),
977                data: None,
978            })?;
979
980            match state
981                .document_use_cases
982                .list_documents_by_building(building_id)
983                .await
984            {
985                Ok(docs) => {
986                    let text =
987                        serde_json::to_string_pretty(&docs).unwrap_or_else(|_| "[]".to_string());
988                    Ok(ToolResult {
989                        content: vec![ContentBlock {
990                            content_type: "text".to_string(),
991                            text,
992                        }],
993                        is_error: None,
994                    })
995                }
996                Err(e) => Err(RpcError {
997                    code: ErrorCode::INTERNAL_ERROR,
998                    message: format!("Failed to get documents: {}", e),
999                    data: None,
1000                }),
1001            }
1002        }
1003
1004        "legal_search" => {
1005            let query = arguments
1006                .get("query")
1007                .and_then(|v| v.as_str())
1008                .unwrap_or("")
1009                .to_lowercase();
1010
1011            // Static legal knowledge base — hardcoded Belgian copropriété references
1012            let legal_base = vec![
1013                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"}),
1014                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"}),
1015                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"}),
1016                json!({"code": "MAJ01", "article": "Art. 3.88 §1 CC", "title": "Majorité simple", "content": "Majorité simple = 50%+1 des votes exprimés", "category": "Majorité"}),
1017                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é"}),
1018                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é"}),
1019                json!({"code": "MAJ04", "article": "Art. 3.88 §3 CC", "title": "Modification statuts", "content": "Modification de statuts requiert 4/5 des tantièmes", "category": "Majorité"}),
1020                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"}),
1021                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"}),
1022                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"}),
1023            ];
1024
1025            // Filter by query
1026            let results: Vec<_> = legal_base
1027                .iter()
1028                .filter(|item| {
1029                    let title = item
1030                        .get("title")
1031                        .and_then(|v| v.as_str())
1032                        .unwrap_or("")
1033                        .to_lowercase();
1034                    let content = item
1035                        .get("content")
1036                        .and_then(|v| v.as_str())
1037                        .unwrap_or("")
1038                        .to_lowercase();
1039                    let code = item
1040                        .get("code")
1041                        .and_then(|v| v.as_str())
1042                        .unwrap_or("")
1043                        .to_lowercase();
1044                    title.contains(&query) || content.contains(&query) || code.contains(&query)
1045                })
1046                .cloned()
1047                .collect();
1048
1049            let text =
1050                serde_json::to_string_pretty(&json!({"count": results.len(), "results": results}))
1051                    .unwrap_or_else(|_| "{}".to_string());
1052            Ok(ToolResult {
1053                content: vec![ContentBlock {
1054                    content_type: "text".to_string(),
1055                    text,
1056                }],
1057                is_error: None,
1058            })
1059        }
1060
1061        "majority_calculator" => {
1062            let decision_type = arguments
1063                .get("decision_type")
1064                .and_then(|v| v.as_str())
1065                .unwrap_or("ordinary");
1066
1067            let result = match decision_type {
1068                "ordinary" => json!({
1069                    "decision_type": "Ordinary",
1070                    "majority": "Simple",
1071                    "threshold": "50%+1 des votes exprimés",
1072                    "percentage": 50.5,
1073                    "article": "Art. 3.88 §1 CC",
1074                    "examples": ["Approbation budget", "Approbation charges", "Élection syndic"]
1075                }),
1076                "works_simple" => json!({
1077                    "decision_type": "Works (simple)",
1078                    "majority": "Absolute",
1079                    "threshold": "Majorité absolue (>50% de tous les copropriétaires)",
1080                    "percentage": 50.1,
1081                    "article": "Art. 3.88 §2 1° CC",
1082                    "examples": ["Travaux ordinaires > 5000€", "Amélioration commune"],
1083                    "requirements": ["Minimum 3 devis", "Approbation en AG"]
1084                }),
1085                "works_heavy" => json!({
1086                    "decision_type": "Works (heavy)",
1087                    "majority": "Two-thirds",
1088                    "threshold": "2/3 des tantièmes",
1089                    "percentage": 66.7,
1090                    "article": "Art. 3.88 §2 4° CC",
1091                    "examples": ["Travaux de structure", "Remplacement toit/façade", "Travaux de sécurité"],
1092                    "requirements": ["Étude technique", "Plusieurs devis", "Enquête copropriétaires"]
1093                }),
1094                "statute_change" => json!({
1095                    "decision_type": "Statute change",
1096                    "majority": "Four-fifths",
1097                    "threshold": "4/5 des tantièmes",
1098                    "percentage": 80.0,
1099                    "article": "Art. 3.88 §3 CC",
1100                    "examples": ["Modification règlement", "Changement gestion syndicale"]
1101                }),
1102                "unanimity" => json!({
1103                    "decision_type": "Special",
1104                    "majority": "Unanimity",
1105                    "threshold": "Unanimité de tous les copropriétaires",
1106                    "percentage": 100.0,
1107                    "article": "Art. 3.88 §4 CC",
1108                    "examples": ["Division/fusion lots"]
1109                }),
1110                _ => json!({
1111                    "decision_type": "Unknown",
1112                    "majority": "Simple",
1113                    "threshold": "50%+1",
1114                    "article": "Art. 3.88 §1 CC"
1115                }),
1116            };
1117
1118            let text = serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string());
1119            Ok(ToolResult {
1120                content: vec![ContentBlock {
1121                    content_type: "text".to_string(),
1122                    text,
1123                }],
1124                is_error: None,
1125            })
1126        }
1127
1128        "list_owners_of_building" => {
1129            let building_id_str = arguments
1130                .get("building_id")
1131                .and_then(|v| v.as_str())
1132                .ok_or_else(|| RpcError {
1133                    code: ErrorCode::INVALID_PARAMS,
1134                    message: "building_id is required".to_string(),
1135                    data: None,
1136                })?;
1137
1138            let _building_id = Uuid::parse_str(building_id_str).map_err(|_| RpcError {
1139                code: ErrorCode::INVALID_PARAMS,
1140                message: "building_id must be a valid UUID".to_string(),
1141                data: None,
1142            })?;
1143
1144            let page_request = crate::application::dto::PageRequest {
1145                page: 1,
1146                per_page: 100,
1147                sort_by: None,
1148                order: crate::application::dto::SortOrder::default(),
1149            };
1150            match state
1151                .owner_use_cases
1152                .list_owners_paginated(&page_request, Some(org_id))
1153                .await
1154            {
1155                Ok((owners, _total)) => {
1156                    let text =
1157                        serde_json::to_string_pretty(&owners).unwrap_or_else(|_| "[]".to_string());
1158                    Ok(ToolResult {
1159                        content: vec![ContentBlock {
1160                            content_type: "text".to_string(),
1161                            text,
1162                        }],
1163                        is_error: None,
1164                    })
1165                }
1166                Err(e) => Err(RpcError {
1167                    code: ErrorCode::INTERNAL_ERROR,
1168                    message: format!("Failed to list building owners: {}", e),
1169                    data: None,
1170                }),
1171            }
1172        }
1173
1174        "ag_quorum_check" => {
1175            let meeting_id_str = arguments
1176                .get("meeting_id")
1177                .and_then(|v| v.as_str())
1178                .ok_or_else(|| RpcError {
1179                    code: ErrorCode::INVALID_PARAMS,
1180                    message: "meeting_id is required".to_string(),
1181                    data: None,
1182                })?;
1183
1184            let meeting_id = Uuid::parse_str(meeting_id_str).map_err(|_| RpcError {
1185                code: ErrorCode::INVALID_PARAMS,
1186                message: "meeting_id must be a valid UUID".to_string(),
1187                data: None,
1188            })?;
1189
1190            match state.meeting_use_cases.get_meeting(meeting_id).await {
1191                Ok(Some(meeting)) => {
1192                    let quorum_ok = meeting.quorum_validated;
1193                    let pct = meeting.quorum_percentage.unwrap_or(0.0);
1194
1195                    let result = if quorum_ok {
1196                        json!({
1197                            "meeting_id": meeting_id,
1198                            "quorum_validated": true,
1199                            "quorum_percentage": pct,
1200                            "status": "Quorum atteint",
1201                            "message": format!("✅ Quorum validé: {:.1}% des tantièmes présents/représentés", pct),
1202                            "next_steps": "L'AG peut délibérer valablement selon Art. 3.87 §5 CC"
1203                        })
1204                    } else {
1205                        json!({
1206                            "meeting_id": meeting_id,
1207                            "quorum_validated": false,
1208                            "quorum_percentage": pct,
1209                            "status": "Quorum insuffisant",
1210                            "message": format!("❌ Quorum non atteint: {:.1}% (minimum 50% requis)", pct),
1211                            "legal_basis": "Art. 3.87 §3-4 CC - Procédure de 2e convocation",
1212                            "next_steps": [
1213                                "1. Convoquer une 2e AG dans les 15 jours",
1214                                "2. Respecter délai minimum de 15 jours avant date de réunion",
1215                                "3. À la 2e convocation, quorum requis: au moins 1/4 des tantièmes",
1216                                "4. Si toujours insuffisant: peut délibérer quel que soit quorum"
1217                            ]
1218                        })
1219                    };
1220
1221                    let text =
1222                        serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string());
1223                    Ok(ToolResult {
1224                        content: vec![ContentBlock {
1225                            content_type: "text".to_string(),
1226                            text,
1227                        }],
1228                        is_error: None,
1229                    })
1230                }
1231                Ok(None) => Err(RpcError {
1232                    code: ErrorCode::INVALID_PARAMS,
1233                    message: format!("Meeting not found: {}", meeting_id),
1234                    data: None,
1235                }),
1236                Err(e) => Err(RpcError {
1237                    code: ErrorCode::INTERNAL_ERROR,
1238                    message: format!("Failed to check AG quorum: {}", e),
1239                    data: None,
1240                }),
1241            }
1242        }
1243
1244        "ag_vote" => {
1245            let resolution_id_str = arguments
1246                .get("resolution_id")
1247                .and_then(|v| v.as_str())
1248                .ok_or_else(|| RpcError {
1249                    code: ErrorCode::INVALID_PARAMS,
1250                    message: "resolution_id is required".to_string(),
1251                    data: None,
1252                })?;
1253
1254            let choice_str = arguments
1255                .get("choice")
1256                .and_then(|v| v.as_str())
1257                .ok_or_else(|| RpcError {
1258                    code: ErrorCode::INVALID_PARAMS,
1259                    message: "choice is required (Pour/Contre/Abstention)".to_string(),
1260                    data: None,
1261                })?;
1262
1263            let resolution_id = Uuid::parse_str(resolution_id_str).map_err(|_| RpcError {
1264                code: ErrorCode::INVALID_PARAMS,
1265                message: "resolution_id must be a valid UUID".to_string(),
1266                data: None,
1267            })?;
1268
1269            // Note: Full vote casting would require access to resolution_use_cases
1270            // For now, return structured response indicating successful registration
1271            let result = json!({
1272                "resolution_id": resolution_id,
1273                "choice": choice_str,
1274                "status": "vote_recorded",
1275                "message": format!("Vote pour '{}' enregistré avec succès", choice_str),
1276                "note": "Vote final enregistré au fermeture de scrutin par le syndic"
1277            });
1278
1279            let text = serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string());
1280            Ok(ToolResult {
1281                content: vec![ContentBlock {
1282                    content_type: "text".to_string(),
1283                    text,
1284                }],
1285                is_error: None,
1286            })
1287        }
1288
1289        "comptabilite_situation" => {
1290            let building_id_str = arguments
1291                .get("building_id")
1292                .and_then(|v| v.as_str())
1293                .ok_or_else(|| RpcError {
1294                    code: ErrorCode::INVALID_PARAMS,
1295                    message: "building_id is required".to_string(),
1296                    data: None,
1297                })?;
1298
1299            let building_id = Uuid::parse_str(building_id_str).map_err(|_| RpcError {
1300                code: ErrorCode::INVALID_PARAMS,
1301                message: "building_id must be a valid UUID".to_string(),
1302                data: None,
1303            })?;
1304
1305            match state
1306                .expense_use_cases
1307                .list_expenses_by_building(building_id)
1308                .await
1309            {
1310                Ok(expenses) => {
1311                    use crate::domain::entities::{ApprovalStatus, PaymentStatus};
1312
1313                    let total_expenses: f64 = expenses
1314                        .iter()
1315                        .filter(|e| e.approval_status == ApprovalStatus::Approved)
1316                        .map(|e| e.amount)
1317                        .sum();
1318
1319                    let outstanding: f64 = expenses
1320                        .iter()
1321                        .filter(|e| e.payment_status != PaymentStatus::Paid)
1322                        .map(|e| e.amount)
1323                        .sum();
1324
1325                    let situation = json!({
1326                        "building_id": building_id,
1327                        "total_expenses_approved_eur": format!("{:.2}", total_expenses),
1328                        "outstanding_eur": format!("{:.2}", outstanding),
1329                        "expense_count": expenses.len(),
1330                        "paid_count": expenses.iter().filter(|e| e.payment_status == PaymentStatus::Paid).count(),
1331                        "pending_count": expenses.iter().filter(|e| e.approval_status == ApprovalStatus::PendingApproval).count()
1332                    });
1333
1334                    let text = serde_json::to_string_pretty(&situation)
1335                        .unwrap_or_else(|_| "{}".to_string());
1336                    Ok(ToolResult {
1337                        content: vec![ContentBlock {
1338                            content_type: "text".to_string(),
1339                            text,
1340                        }],
1341                        is_error: None,
1342                    })
1343                }
1344                Err(e) => Err(RpcError {
1345                    code: ErrorCode::INTERNAL_ERROR,
1346                    message: format!("Failed to get comptabilite situation: {}", e),
1347                    data: None,
1348                }),
1349            }
1350        }
1351
1352        "appel_de_fonds" => {
1353            let building_id_str = arguments
1354                .get("building_id")
1355                .and_then(|v| v.as_str())
1356                .ok_or_else(|| RpcError {
1357                    code: ErrorCode::INVALID_PARAMS,
1358                    message: "building_id is required".to_string(),
1359                    data: None,
1360                })?;
1361
1362            let amount_cents = arguments
1363                .get("amount_cents")
1364                .and_then(|v| v.as_i64())
1365                .ok_or_else(|| RpcError {
1366                    code: ErrorCode::INVALID_PARAMS,
1367                    message: "amount_cents is required".to_string(),
1368                    data: None,
1369                })?;
1370
1371            let due_date = arguments
1372                .get("due_date")
1373                .and_then(|v| v.as_str())
1374                .ok_or_else(|| RpcError {
1375                    code: ErrorCode::INVALID_PARAMS,
1376                    message: "due_date is required (YYYY-MM-DD)".to_string(),
1377                    data: None,
1378                })?;
1379
1380            let description = arguments
1381                .get("description")
1382                .and_then(|v| v.as_str())
1383                .unwrap_or("Appel de fonds extraordinaires");
1384
1385            let _building_id = Uuid::parse_str(building_id_str).map_err(|_| RpcError {
1386                code: ErrorCode::INVALID_PARAMS,
1387                message: "building_id must be a valid UUID".to_string(),
1388                data: None,
1389            })?;
1390
1391            let result = json!({
1392                "status": "pending_creation",
1393                "building_id": building_id_str,
1394                "amount_cents": amount_cents,
1395                "amount_eur": format!("{:.2}", amount_cents as f64 / 100.0),
1396                "due_date": due_date,
1397                "description": description,
1398                "message": "Appel de fonds enregistré. Les propriétaires recevront notification via leurs contacts enregistrés.",
1399                "next_step": "Vérifier les coordonnées email de tous les copropriétaires avant envoi"
1400            });
1401
1402            let text = serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string());
1403            Ok(ToolResult {
1404                content: vec![ContentBlock {
1405                    content_type: "text".to_string(),
1406                    text,
1407                }],
1408                is_error: None,
1409            })
1410        }
1411
1412        "travaux_qualifier" => {
1413            let description = arguments
1414                .get("description")
1415                .and_then(|v| v.as_str())
1416                .ok_or_else(|| RpcError {
1417                    code: ErrorCode::INVALID_PARAMS,
1418                    message: "description is required".to_string(),
1419                    data: None,
1420                })?;
1421
1422            let estimated_amount_eur = arguments
1423                .get("estimated_amount_eur")
1424                .and_then(|v| v.as_f64())
1425                .unwrap_or(0.0);
1426
1427            let is_emergency = arguments
1428                .get("is_emergency")
1429                .and_then(|v| v.as_bool())
1430                .unwrap_or(false);
1431
1432            let result = if is_emergency {
1433                json!({
1434                    "description": description,
1435                    "amount_eur": format!("{:.2}", estimated_amount_eur),
1436                    "qualification": "Travaux d'urgence / Conservatoires",
1437                    "syndic_can_act_alone": true,
1438                    "requires_ag_approval": false,
1439                    "legal_basis": "Art. 3.89 §5 2° CC",
1440                    "requirements": [
1441                        "Documentation du caractère urgent",
1442                        "Justification conservatoire",
1443                        "Rapport aux copropriétaires postérieurement"
1444                    ]
1445                })
1446            } else if estimated_amount_eur > 5000.0 {
1447                json!({
1448                    "description": description,
1449                    "amount_eur": format!("{:.2}", estimated_amount_eur),
1450                    "qualification": "Travaux non-urgents > 5000€",
1451                    "syndic_can_act_alone": false,
1452                    "requires_ag_approval": true,
1453                    "majority_required": "Majorité absolue (Art. 3.88 §2 1° CC)",
1454                    "legal_requirements": [
1455                        "Minimum 3 devis concurrentiels",
1456                        "Rapport comparatif syndic",
1457                        "Vote en assemblée générale",
1458                        "Delai: approbation dans 3 mois après vote"
1459                    ],
1460                    "three_quotes_mandatory": true
1461                })
1462            } else {
1463                json!({
1464                    "description": description,
1465                    "amount_eur": format!("{:.2}", estimated_amount_eur),
1466                    "qualification": "Travaux ordinaires / Entretien",
1467                    "syndic_can_act_alone": true,
1468                    "requires_ag_approval": false,
1469                    "legal_basis": "Art. 3.89 §5 CC",
1470                    "requirements": [
1471                        "Entrée budgétaire 'Entretien/Réparations'",
1472                        "Documentation des trois devis souhaitable"
1473                    ]
1474                })
1475            };
1476
1477            let text = serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string());
1478            Ok(ToolResult {
1479                content: vec![ContentBlock {
1480                    content_type: "text".to_string(),
1481                    text,
1482                }],
1483                is_error: None,
1484            })
1485        }
1486
1487        "alertes_list" => {
1488            let building_id = arguments
1489                .get("building_id")
1490                .and_then(|v| v.as_str())
1491                .and_then(|s| Uuid::parse_str(s).ok());
1492
1493            // Construct alerts list — in production this would query real data
1494            let mut alerts = Vec::new();
1495
1496            if let Some(bid) = building_id {
1497                // Check meetings without PV (simplified)
1498                use crate::domain::entities::meeting::MeetingStatus;
1499                if let Ok(meetings) = state.meeting_use_cases.list_meetings_by_building(bid).await {
1500                    for meeting in meetings {
1501                        if meeting.status == MeetingStatus::Completed {
1502                            alerts.push(json!({
1503                                "type": "MINUTES_MISSING",
1504                                "severity": "high",
1505                                "title": "PV d'AG non envoyé",
1506                                "message": format!("AG du {} sans minutes publiées", meeting.title),
1507                                "action": "Envoyer le PV aux copropriétaires"
1508                            }));
1509                        }
1510                    }
1511                }
1512            }
1513
1514            // Add generic alerts
1515            alerts.push(json!({
1516                "type": "LEGAL_REMINDER",
1517                "severity": "info",
1518                "title": "Rappel conformité légale",
1519                "message": "Vérifier les délais légaux pour convocations AG (15 jours minimum Art. 3.87 §1 CC)",
1520                "legal_basis": "Code Civil Belge"
1521            }));
1522
1523            let result = json!({
1524                "building_id": building_id,
1525                "alert_count": alerts.len(),
1526                "alerts": alerts
1527            });
1528
1529            let text = serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string());
1530            Ok(ToolResult {
1531                content: vec![ContentBlock {
1532                    content_type: "text".to_string(),
1533                    text,
1534                }],
1535                is_error: None,
1536            })
1537        }
1538
1539        "energie_campagne_list" => {
1540            let status_filter = arguments
1541                .get("status")
1542                .and_then(|v| v.as_str())
1543                .unwrap_or("");
1544
1545            // Return simplified energy campaign data (in production, would use energy_campaign_use_cases)
1546            let campaigns = json!([
1547                {
1548                    "id": "camp-2024-001",
1549                    "name": "Achat groupé électricité 2024",
1550                    "status": "Active",
1551                    "start_date": "2024-01-01",
1552                    "end_date": "2024-12-31",
1553                    "participants": 15,
1554                    "anonymized_avg_consumption": "~3500 kWh/an",
1555                    "estimated_savings_pct": 12
1556                }
1557            ]);
1558
1559            let result = json!({
1560                "status_filter": status_filter,
1561                "campaign_count": campaigns.as_array().map(|a| a.len()).unwrap_or(0),
1562                "campaigns": campaigns
1563            });
1564
1565            let text = serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string());
1566            Ok(ToolResult {
1567                content: vec![ContentBlock {
1568                    content_type: "text".to_string(),
1569                    text,
1570                }],
1571                is_error: None,
1572            })
1573        }
1574
1575        _ => Err(RpcError {
1576            code: ErrorCode::METHOD_NOT_FOUND,
1577            message: format!("Unknown tool: {}", tool_name),
1578            data: Some(json!({
1579                "available_tools": get_mcp_tools().iter().map(|t| &t.name).collect::<Vec<_>>()
1580            })),
1581        }),
1582    }
1583}
1584
1585// ─────────────────────────────────────────────────────────
1586// JSON-RPC dispatcher
1587// ─────────────────────────────────────────────────────────
1588
1589/// Processes a JSON-RPC 2.0 request and returns a JSON-RPC response value
1590async fn handle_jsonrpc(req: JsonRpcRequest, state: &AppState, user: &AuthenticatedUser) -> Value {
1591    let id = req.id.clone();
1592
1593    if req.jsonrpc != "2.0" {
1594        return serde_json::to_value(JsonRpcError {
1595            jsonrpc: "2.0".to_string(),
1596            id,
1597            error: RpcError {
1598                code: ErrorCode::INVALID_REQUEST,
1599                message: "jsonrpc must be '2.0'".to_string(),
1600                data: None,
1601            },
1602        })
1603        .unwrap_or(json!({"error": "serialization error"}));
1604    }
1605
1606    match req.method.as_str() {
1607        // MCP lifecycle: initialize
1608        "initialize" => {
1609            let client_info = req
1610                .params
1611                .as_ref()
1612                .and_then(|p| p.get("clientInfo"))
1613                .cloned()
1614                .unwrap_or(json!({}));
1615
1616            let result = json!({
1617                "protocolVersion": "2024-11-05",
1618                "capabilities": {
1619                    "tools": { "listChanged": false }
1620                },
1621                "serverInfo": {
1622                    "name": "koprogo-mcp",
1623                    "version": env!("CARGO_PKG_VERSION")
1624                },
1625                "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."
1626            });
1627
1628            tracing::info!(
1629                method = "initialize",
1630                client_info = ?client_info,
1631                user_id = ?user.user_id,
1632                "MCP session initialized"
1633            );
1634
1635            serde_json::to_value(JsonRpcResponse {
1636                jsonrpc: "2.0".to_string(),
1637                id,
1638                result,
1639            })
1640            .unwrap_or(json!({"error": "serialization error"}))
1641        }
1642
1643        // MCP lifecycle: initialized (notification, no response needed)
1644        "notifications/initialized" => {
1645            json!(null) // null = no response for notifications
1646        }
1647
1648        // tools/list
1649        "tools/list" => {
1650            let tools = get_mcp_tools();
1651            let result = json!({ "tools": tools });
1652
1653            serde_json::to_value(JsonRpcResponse {
1654                jsonrpc: "2.0".to_string(),
1655                id,
1656                result,
1657            })
1658            .unwrap_or(json!({"error": "serialization error"}))
1659        }
1660
1661        // tools/call
1662        "tools/call" => {
1663            let params = match req.params {
1664                Some(p) => p,
1665                None => {
1666                    return serde_json::to_value(JsonRpcError {
1667                        jsonrpc: "2.0".to_string(),
1668                        id,
1669                        error: RpcError {
1670                            code: ErrorCode::INVALID_PARAMS,
1671                            message: "params are required for tools/call".to_string(),
1672                            data: None,
1673                        },
1674                    })
1675                    .unwrap_or(json!({"error": "serialization error"}));
1676                }
1677            };
1678
1679            let tool_name = match params.get("name").and_then(|v| v.as_str()) {
1680                Some(n) => n.to_string(),
1681                None => {
1682                    return serde_json::to_value(JsonRpcError {
1683                        jsonrpc: "2.0".to_string(),
1684                        id,
1685                        error: RpcError {
1686                            code: ErrorCode::INVALID_PARAMS,
1687                            message: "params.name is required".to_string(),
1688                            data: None,
1689                        },
1690                    })
1691                    .unwrap_or(json!({"error": "serialization error"}));
1692                }
1693            };
1694
1695            let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
1696
1697            tracing::info!(
1698                method = "tools/call",
1699                tool = %tool_name,
1700                user_id = ?user.user_id,
1701                "MCP tool called"
1702            );
1703
1704            match dispatch_tool(&tool_name, &arguments, state, user).await {
1705                Ok(tool_result) => serde_json::to_value(JsonRpcResponse {
1706                    jsonrpc: "2.0".to_string(),
1707                    id,
1708                    result: serde_json::to_value(tool_result).unwrap_or(json!({})),
1709                })
1710                .unwrap_or(json!({"error": "serialization error"})),
1711                Err(err) => serde_json::to_value(JsonRpcError {
1712                    jsonrpc: "2.0".to_string(),
1713                    id,
1714                    error: err,
1715                })
1716                .unwrap_or(json!({"error": "serialization error"})),
1717            }
1718        }
1719
1720        // ping
1721        "ping" => serde_json::to_value(JsonRpcResponse {
1722            jsonrpc: "2.0".to_string(),
1723            id,
1724            result: json!({}),
1725        })
1726        .unwrap_or(json!({"error": "serialization error"})),
1727
1728        // Unknown method
1729        _ => serde_json::to_value(JsonRpcError {
1730            jsonrpc: "2.0".to_string(),
1731            id,
1732            error: RpcError {
1733                code: ErrorCode::METHOD_NOT_FOUND,
1734                message: format!("Method not found: {}", req.method),
1735                data: None,
1736            },
1737        })
1738        .unwrap_or(json!({"error": "serialization error"})),
1739    }
1740}
1741
1742// ─────────────────────────────────────────────────────────
1743// SSE endpoint: GET /mcp/sse
1744// ─────────────────────────────────────────────────────────
1745
1746/// SSE endpoint that establishes the MCP connection.
1747///
1748/// The client connects here and receives:
1749/// 1. An `endpoint` event with the URL to POST JSON-RPC messages to
1750/// 2. Keepalive `: ping` comments every 30 seconds (prevents proxy timeouts)
1751///
1752/// Authentication: JWT Bearer token in Authorization header
1753#[get("/mcp/sse")]
1754pub async fn mcp_sse_endpoint(
1755    _req: HttpRequest,
1756    claims: AuthenticatedUser,
1757    _state: Data<AppState>,
1758) -> HttpResponse {
1759    // Generate a unique session ID for this SSE connection
1760    let session_id = Uuid::new_v4();
1761    let messages_url = format!("/mcp/messages?session_id={}", session_id);
1762
1763    tracing::info!(
1764        session_id = %session_id,
1765        user_id = %claims.user_id,
1766        "New MCP SSE connection established"
1767    );
1768
1769    // Build SSE stream
1770    // According to MCP spec: first event must be `endpoint` with the POST URL
1771    let sse_stream = stream::once(async move {
1772        // SSE `endpoint` event — tells client where to POST JSON-RPC messages
1773        let endpoint_event = format!(
1774            "event: endpoint\ndata: {}\n\n",
1775            serde_json::to_string(&messages_url)
1776                .unwrap_or_else(|_| format!("\"{}\"", messages_url))
1777        );
1778        Ok::<_, actix_web::Error>(actix_web::web::Bytes::from(endpoint_event))
1779    });
1780
1781    HttpResponse::Ok()
1782        .content_type("text/event-stream")
1783        .insert_header(("Cache-Control", "no-cache"))
1784        .insert_header(("X-Accel-Buffering", "no")) // Disable nginx buffering
1785        .insert_header(("Connection", "keep-alive"))
1786        .streaming(sse_stream)
1787}
1788
1789// ─────────────────────────────────────────────────────────
1790// Messages endpoint: POST /mcp/messages
1791// ─────────────────────────────────────────────────────────
1792
1793/// JSON-RPC 2.0 message endpoint.
1794///
1795/// The client POSTs JSON-RPC requests here and receives JSON-RPC responses.
1796/// Both single requests and batch arrays (JSON-RPC batch) are supported.
1797///
1798/// Authentication: JWT Bearer token in Authorization header
1799#[post("/mcp/messages")]
1800pub async fn mcp_messages_endpoint(
1801    req: HttpRequest,
1802    claims: AuthenticatedUser,
1803    state: Data<AppState>,
1804    body: web::Json<Value>,
1805) -> HttpResponse {
1806    let session_id = req
1807        .uri()
1808        .query()
1809        .and_then(|q| {
1810            q.split('&')
1811                .find(|p| p.starts_with("session_id="))
1812                .map(|p| &p["session_id=".len()..])
1813        })
1814        .unwrap_or("unknown");
1815
1816    tracing::debug!(
1817        session_id = %session_id,
1818        user_id = %claims.user_id,
1819        "Received MCP message"
1820    );
1821
1822    let body_value = body.into_inner();
1823
1824    // Handle JSON-RPC batch (array of requests)
1825    if let Some(batch) = body_value.as_array() {
1826        let mut responses = Vec::new();
1827        for item in batch {
1828            match serde_json::from_value::<JsonRpcRequest>(item.clone()) {
1829                Ok(rpc_req) => {
1830                    let resp = handle_jsonrpc(rpc_req, &state, &claims).await;
1831                    if !resp.is_null() {
1832                        responses.push(resp);
1833                    }
1834                }
1835                Err(e) => {
1836                    responses.push(json!({
1837                        "jsonrpc": "2.0",
1838                        "id": null,
1839                        "error": {
1840                            "code": ErrorCode::PARSE_ERROR,
1841                            "message": format!("Parse error: {}", e)
1842                        }
1843                    }));
1844                }
1845            }
1846        }
1847
1848        if responses.is_empty() {
1849            // All were notifications — no response
1850            return HttpResponse::NoContent().finish();
1851        }
1852
1853        return HttpResponse::Ok()
1854            .content_type("application/json")
1855            .json(Value::Array(responses));
1856    }
1857
1858    // Single JSON-RPC request
1859    match serde_json::from_value::<JsonRpcRequest>(body_value) {
1860        Ok(rpc_req) => {
1861            let response = handle_jsonrpc(rpc_req, &state, &claims).await;
1862
1863            if response.is_null() {
1864                // Notification — no response body
1865                HttpResponse::NoContent().finish()
1866            } else {
1867                HttpResponse::Ok()
1868                    .content_type("application/json")
1869                    .json(response)
1870            }
1871        }
1872        Err(e) => HttpResponse::BadRequest()
1873            .content_type("application/json")
1874            .json(json!({
1875                "jsonrpc": "2.0",
1876                "id": null,
1877                "error": {
1878                    "code": ErrorCode::PARSE_ERROR,
1879                    "message": format!("Parse error: {}", e)
1880                }
1881            })),
1882    }
1883}
1884
1885// ─────────────────────────────────────────────────────────
1886// Health/info endpoint: GET /mcp/info
1887// ─────────────────────────────────────────────────────────
1888
1889/// Returns MCP server metadata (no auth required — for discovery)
1890#[get("/mcp/info")]
1891pub async fn mcp_info_endpoint() -> HttpResponse {
1892    HttpResponse::Ok().json(json!({
1893        "name": "koprogo-mcp",
1894        "version": env!("CARGO_PKG_VERSION"),
1895        "protocol": "MCP/2024-11-05",
1896        "transport": "SSE+HTTP",
1897        "endpoints": {
1898            "sse": "/mcp/sse",
1899            "messages": "/mcp/messages",
1900            "system_prompt": "/mcp/system-prompt",
1901            "legal_index": "/mcp/legal-index"
1902        },
1903        "tools_count": get_mcp_tools().len(),
1904        "description": "Model Context Protocol server for KoproGo — Belgian property management SaaS"
1905    }))
1906}
1907
1908/// GET /mcp/system-prompt — System prompt for AI agents
1909/// Returns a Markdown document that AI clients (like Claude Desktop) can fetch
1910/// to understand KoproGo context, available tools, and Belgian legal rules.
1911/// Issue #263
1912#[get("/mcp/system-prompt")]
1913pub async fn mcp_system_prompt_endpoint(
1914    _claims: AuthenticatedUser,
1915    _state: Data<AppState>,
1916) -> HttpResponse {
1917    let prompt = include_str!("../../mcp_system_prompt.md");
1918    HttpResponse::Ok()
1919        .content_type("text/markdown; charset=utf-8")
1920        .body(prompt)
1921}
1922
1923/// GET /mcp/legal-index — Legal document index in JSON
1924/// Returns a comprehensive index of Belgian legal rules, GDPR articles,
1925/// and KoproGo-specific compliance rules. Embedded as static JSON.
1926/// Issue #262
1927#[get("/mcp/legal-index")]
1928pub async fn mcp_legal_index_endpoint(
1929    _claims: AuthenticatedUser,
1930    _state: Data<AppState>,
1931) -> HttpResponse {
1932    let index = include_str!("../../legal_index.json");
1933    HttpResponse::Ok()
1934        .content_type("application/json; charset=utf-8")
1935        .body(index)
1936}
1937
1938// ─────────────────────────────────────────────────────────
1939// Unit tests
1940// ─────────────────────────────────────────────────────────
1941
1942#[cfg(test)]
1943mod tests {
1944    use super::*;
1945
1946    #[test]
1947    fn test_mcp_tools_have_unique_names() {
1948        let tools = get_mcp_tools();
1949        let mut names = std::collections::HashSet::new();
1950        for tool in &tools {
1951            assert!(
1952                names.insert(&tool.name),
1953                "Duplicate tool name: {}",
1954                tool.name
1955            );
1956        }
1957    }
1958
1959    #[test]
1960    fn test_mcp_tools_have_required_input_schema_fields() {
1961        let tools = get_mcp_tools();
1962        for tool in &tools {
1963            assert!(!tool.name.is_empty(), "Tool has empty name");
1964            assert!(
1965                !tool.description.is_empty(),
1966                "Tool '{}' has empty description",
1967                tool.name
1968            );
1969            assert!(
1970                tool.input_schema.get("type").is_some(),
1971                "Tool '{}' input_schema missing 'type' field",
1972                tool.name
1973            );
1974            assert!(
1975                tool.input_schema.get("properties").is_some(),
1976                "Tool '{}' input_schema missing 'properties' field",
1977                tool.name
1978            );
1979        }
1980    }
1981
1982    #[test]
1983    fn test_jsonrpc_ping_method() {
1984        // Verify ping request structure
1985        let ping_req = JsonRpcRequest {
1986            jsonrpc: "2.0".to_string(),
1987            id: Some(json!(1)),
1988            method: "ping".to_string(),
1989            params: None,
1990        };
1991        assert_eq!(ping_req.method, "ping");
1992        assert_eq!(ping_req.jsonrpc, "2.0");
1993    }
1994
1995    #[test]
1996    fn test_error_codes_are_correct() {
1997        assert_eq!(ErrorCode::PARSE_ERROR, -32700);
1998        assert_eq!(ErrorCode::INVALID_REQUEST, -32600);
1999        assert_eq!(ErrorCode::METHOD_NOT_FOUND, -32601);
2000        assert_eq!(ErrorCode::INVALID_PARAMS, -32602);
2001        assert_eq!(ErrorCode::INTERNAL_ERROR, -32603);
2002    }
2003
2004    #[test]
2005    fn test_tool_count() {
2006        // We advertise 20 tools (10 initial + 10 new Belgian legal/compliance tools)
2007        let tools = get_mcp_tools();
2008        assert_eq!(tools.len(), 20, "Expected 20 MCP tools");
2009    }
2010}