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