1use 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#[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#[derive(Debug, Serialize)]
46pub struct JsonRpcResponse {
47 pub jsonrpc: String,
48 pub id: Option<Value>,
49 pub result: Value,
50}
51
52#[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
67struct 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#[derive(Debug, Serialize)]
83pub struct ServerInfo {
84 pub name: String,
85 pub version: String,
86}
87
88#[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#[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#[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
124fn 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
511async 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 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 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 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 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 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 let mut alerts = Vec::new();
1495
1496 if let Some(bid) = building_id {
1497 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 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 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
1585async 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 "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 "notifications/initialized" => {
1645 json!(null) }
1647
1648 "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" => {
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" => 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 _ => 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#[get("/mcp/sse")]
1754pub async fn mcp_sse_endpoint(
1755 _req: HttpRequest,
1756 claims: AuthenticatedUser,
1757 _state: Data<AppState>,
1758) -> HttpResponse {
1759 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 let sse_stream = stream::once(async move {
1772 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")) .insert_header(("Connection", "keep-alive"))
1786 .streaming(sse_stream)
1787}
1788
1789#[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 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 return HttpResponse::NoContent().finish();
1851 }
1852
1853 return HttpResponse::Ok()
1854 .content_type("application/json")
1855 .json(Value::Array(responses));
1856 }
1857
1858 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 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#[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")]
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")]
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#[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 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 let tools = get_mcp_tools();
2008 assert_eq!(tools.len(), 20, "Expected 20 MCP tools");
2009 }
2010}