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