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