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 std::sync::Arc;
30use uuid::Uuid;
31
32#[derive(Debug, Deserialize)]
38pub struct JsonRpcRequest {
39 pub jsonrpc: String,
40 pub id: Option<Value>,
41 pub method: String,
42 pub params: Option<Value>,
43}
44
45#[derive(Debug, Serialize)]
47pub struct JsonRpcResponse {
48 pub jsonrpc: String,
49 pub id: Option<Value>,
50 pub result: Value,
51}
52
53#[derive(Debug, Serialize)]
55pub struct JsonRpcError {
56 pub jsonrpc: String,
57 pub id: Option<Value>,
58 pub error: RpcError,
59}
60
61#[derive(Debug, Serialize)]
62pub struct RpcError {
63 pub code: i32,
64 pub message: String,
65 pub data: Option<Value>,
66}
67
68struct ErrorCode;
70impl ErrorCode {
71 const PARSE_ERROR: i32 = -32700;
72 const INVALID_REQUEST: i32 = -32600;
73 const METHOD_NOT_FOUND: i32 = -32601;
74 const INVALID_PARAMS: i32 = -32602;
75 const INTERNAL_ERROR: i32 = -32603;
76}
77
78#[derive(Debug, Serialize)]
84pub struct ServerInfo {
85 pub name: String,
86 pub version: String,
87}
88
89#[derive(Debug, Serialize)]
91pub struct ServerCapabilities {
92 pub tools: ToolsCapability,
93}
94
95#[derive(Debug, Serialize)]
96pub struct ToolsCapability {
97 #[serde(rename = "listChanged")]
98 pub list_changed: bool,
99}
100
101#[derive(Debug, Serialize, Clone)]
103pub struct McpTool {
104 pub name: String,
105 pub description: String,
106 #[serde(rename = "inputSchema")]
107 pub input_schema: Value,
108}
109
110#[derive(Debug, Serialize)]
112pub struct ToolResult {
113 pub content: Vec<ContentBlock>,
114 #[serde(rename = "isError", skip_serializing_if = "Option::is_none")]
115 pub is_error: Option<bool>,
116}
117
118#[derive(Debug, Serialize)]
119pub struct ContentBlock {
120 #[serde(rename = "type")]
121 pub content_type: String,
122 pub text: String,
123}
124
125fn get_mcp_tools() -> Vec<McpTool> {
131 vec![
132 McpTool {
133 name: "list_buildings".to_string(),
134 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(),
135 input_schema: json!({
136 "type": "object",
137 "properties": {
138 "page": {
139 "type": "integer",
140 "description": "Numéro de page (défaut: 1)"
141 },
142 "per_page": {
143 "type": "integer",
144 "description": "Résultats par page (défaut: 20, max: 100)"
145 }
146 },
147 "required": []
148 }),
149 },
150 McpTool {
151 name: "get_building".to_string(),
152 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(),
153 input_schema: json!({
154 "type": "object",
155 "properties": {
156 "building_id": {
157 "type": "string",
158 "format": "uuid",
159 "description": "UUID de l'immeuble"
160 }
161 },
162 "required": ["building_id"]
163 }),
164 },
165 McpTool {
166 name: "list_owners".to_string(),
167 description: "Liste les copropriétaires d'un immeuble avec leurs quotes-parts (tantièmes/millièmes), coordonnées et informations de contact.".to_string(),
168 input_schema: json!({
169 "type": "object",
170 "properties": {
171 "building_id": {
172 "type": "string",
173 "format": "uuid",
174 "description": "UUID de l'immeuble (optionnel, liste tous si absent)"
175 }
176 },
177 "required": []
178 }),
179 },
180 McpTool {
181 name: "list_meetings".to_string(),
182 description: "Liste les assemblées générales (AG) d'un immeuble: date, type (AGO/AGE), statut, quorum validé, résolutions prises.".to_string(),
183 input_schema: json!({
184 "type": "object",
185 "properties": {
186 "building_id": {
187 "type": "string",
188 "format": "uuid",
189 "description": "UUID de l'immeuble"
190 },
191 "status": {
192 "type": "string",
193 "enum": ["Scheduled", "Completed", "Cancelled"],
194 "description": "Filtrer par statut (optionnel)"
195 }
196 },
197 "required": ["building_id"]
198 }),
199 },
200 McpTool {
201 name: "get_financial_summary".to_string(),
202 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(),
203 input_schema: json!({
204 "type": "object",
205 "properties": {
206 "building_id": {
207 "type": "string",
208 "format": "uuid",
209 "description": "UUID de l'immeuble"
210 }
211 },
212 "required": ["building_id"]
213 }),
214 },
215 McpTool {
216 name: "list_tickets".to_string(),
217 description: "Liste les tickets de maintenance d'un immeuble: interventions en cours, priorités, prestataires assignés, délais de résolution.".to_string(),
218 input_schema: json!({
219 "type": "object",
220 "properties": {
221 "building_id": {
222 "type": "string",
223 "format": "uuid",
224 "description": "UUID de l'immeuble"
225 },
226 "status": {
227 "type": "string",
228 "enum": ["Open", "Assigned", "InProgress", "Resolved", "Closed", "Cancelled"],
229 "description": "Filtrer par statut (optionnel)"
230 }
231 },
232 "required": ["building_id"]
233 }),
234 },
235 McpTool {
236 name: "get_owner_balance".to_string(),
237 description: "Solde et historique de paiements d'un copropriétaire: montants dus, paiements effectués, retards, relances envoyées.".to_string(),
238 input_schema: json!({
239 "type": "object",
240 "properties": {
241 "owner_id": {
242 "type": "string",
243 "format": "uuid",
244 "description": "UUID du copropriétaire"
245 }
246 },
247 "required": ["owner_id"]
248 }),
249 },
250 McpTool {
251 name: "list_pending_expenses".to_string(),
252 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(),
253 input_schema: json!({
254 "type": "object",
255 "properties": {
256 "building_id": {
257 "type": "string",
258 "format": "uuid",
259 "description": "UUID de l'immeuble (optionnel)"
260 },
261 "status": {
262 "type": "string",
263 "enum": ["Draft", "PendingApproval", "Approved", "Rejected", "Paid", "Overdue", "Cancelled"],
264 "description": "Filtrer par statut (défaut: PendingApproval)"
265 }
266 },
267 "required": []
268 }),
269 },
270 McpTool {
271 name: "check_quorum".to_string(),
272 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(),
273 input_schema: json!({
274 "type": "object",
275 "properties": {
276 "meeting_id": {
277 "type": "string",
278 "format": "uuid",
279 "description": "UUID de l'assemblée générale"
280 }
281 },
282 "required": ["meeting_id"]
283 }),
284 },
285 McpTool {
286 name: "get_building_documents".to_string(),
287 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(),
288 input_schema: json!({
289 "type": "object",
290 "properties": {
291 "building_id": {
292 "type": "string",
293 "format": "uuid",
294 "description": "UUID de l'immeuble"
295 },
296 "document_type": {
297 "type": "string",
298 "description": "Filtrer par type: Minutes, Contract, Invoice, Quote, Report, Budget, Other (optionnel)"
299 }
300 },
301 "required": ["building_id"]
302 }),
303 },
304 McpTool {
305 name: "legal_search".to_string(),
306 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(),
307 input_schema: json!({
308 "type": "object",
309 "properties": {
310 "query": {
311 "type": "string",
312 "description": "Mot-clé à rechercher (ex: 'quorum', 'majorité', 'convocation')"
313 },
314 "code": {
315 "type": "string",
316 "description": "Code d'article (ex: 'Art. 3.87 §1 CC') (optionnel)"
317 },
318 "category": {
319 "type": "string",
320 "description": "Catégorie légale: AG, Travaux, Majorité, Quorum, Convocation, Finances (optionnel)"
321 }
322 },
323 "required": ["query"]
324 }),
325 },
326 McpTool {
327 name: "majority_calculator".to_string(),
328 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(),
329 input_schema: json!({
330 "type": "object",
331 "properties": {
332 "decision_type": {
333 "type": "string",
334 "enum": ["ordinary", "works_simple", "works_heavy", "statute_change", "unanimity"],
335 "description": "Type de décision (AGO, travaux simples, travaux lourds, modification statuts, unanimité)"
336 },
337 "building_id": {
338 "type": "string",
339 "format": "uuid",
340 "description": "UUID de l'immeuble (optionnel, pour contexte)"
341 },
342 "meeting_id": {
343 "type": "string",
344 "format": "uuid",
345 "description": "UUID de l'assemblée (optionnel, pour contexte)"
346 }
347 },
348 "required": ["decision_type"]
349 }),
350 },
351 McpTool {
352 name: "list_owners_of_building".to_string(),
353 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(),
354 input_schema: json!({
355 "type": "object",
356 "properties": {
357 "building_id": {
358 "type": "string",
359 "format": "uuid",
360 "description": "UUID de l'immeuble"
361 },
362 "include_inactive": {
363 "type": "boolean",
364 "description": "Inclure les propriétaires inactifs/historiques (défaut: false)"
365 }
366 },
367 "required": ["building_id"]
368 }),
369 },
370 McpTool {
371 name: "ag_quorum_check".to_string(),
372 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(),
373 input_schema: json!({
374 "type": "object",
375 "properties": {
376 "meeting_id": {
377 "type": "string",
378 "format": "uuid",
379 "description": "UUID de l'assemblée générale"
380 }
381 },
382 "required": ["meeting_id"]
383 }),
384 },
385 McpTool {
386 name: "ag_vote".to_string(),
387 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(),
388 input_schema: json!({
389 "type": "object",
390 "properties": {
391 "resolution_id": {
392 "type": "string",
393 "format": "uuid",
394 "description": "UUID de la résolution"
395 },
396 "choice": {
397 "type": "string",
398 "enum": ["Pour", "Contre", "Abstention"],
399 "description": "Choix de vote"
400 },
401 "proxy_owner_id": {
402 "type": "string",
403 "format": "uuid",
404 "description": "UUID du mandataire si vote par procuration (optionnel)"
405 }
406 },
407 "required": ["resolution_id", "choice"]
408 }),
409 },
410 McpTool {
411 name: "comptabilite_situation".to_string(),
412 description: "Situation comptable d'un immeuble: soldes comptes, arriérés de charges, revenus, dépenses. Retourne bilan financier détaillé.".to_string(),
413 input_schema: json!({
414 "type": "object",
415 "properties": {
416 "building_id": {
417 "type": "string",
418 "format": "uuid",
419 "description": "UUID de l'immeuble"
420 },
421 "fiscal_year": {
422 "type": "integer",
423 "description": "Année fiscale (optionnel, défaut: année courante)"
424 }
425 },
426 "required": ["building_id"]
427 }),
428 },
429 McpTool {
430 name: "appel_de_fonds".to_string(),
431 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(),
432 input_schema: json!({
433 "type": "object",
434 "properties": {
435 "building_id": {
436 "type": "string",
437 "format": "uuid",
438 "description": "UUID de l'immeuble"
439 },
440 "amount_cents": {
441 "type": "integer",
442 "description": "Montant total en centimes d'euros"
443 },
444 "due_date": {
445 "type": "string",
446 "format": "date",
447 "description": "Date d'échéance (YYYY-MM-DD)"
448 },
449 "description": {
450 "type": "string",
451 "description": "Description du motif de l'appel (ex: 'Rénovation toiture')"
452 }
453 },
454 "required": ["building_id", "amount_cents", "due_date", "description"]
455 }),
456 },
457 McpTool {
458 name: "travaux_qualifier".to_string(),
459 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(),
460 input_schema: json!({
461 "type": "object",
462 "properties": {
463 "description": {
464 "type": "string",
465 "description": "Description des travaux"
466 },
467 "estimated_amount_eur": {
468 "type": "number",
469 "description": "Montant estimé en euros"
470 },
471 "is_emergency": {
472 "type": "boolean",
473 "description": "Travaux d'urgence? (conservatoires, sécurité)"
474 }
475 },
476 "required": ["description", "estimated_amount_eur", "is_emergency"]
477 }),
478 },
479 McpTool {
480 name: "alertes_list".to_string(),
481 description: "Liste les alertes de conformité actives: mandats de syndic expirés, AG sans PV, paiements en retard, contrats expirés.".to_string(),
482 input_schema: json!({
483 "type": "object",
484 "properties": {
485 "building_id": {
486 "type": "string",
487 "format": "uuid",
488 "description": "UUID de l'immeuble (optionnel, liste tous si absent)"
489 }
490 },
491 "required": []
492 }),
493 },
494 McpTool {
495 name: "energie_campagne_list".to_string(),
496 description: "Liste les campagnes d'achat groupé d'énergie de l'organisation: statut participation, offres reçues, économies estimées.".to_string(),
497 input_schema: json!({
498 "type": "object",
499 "properties": {
500 "status": {
501 "type": "string",
502 "enum": ["Draft", "Active", "Completed", "Cancelled"],
503 "description": "Filtrer par statut (optionnel)"
504 }
505 },
506 "required": []
507 }),
508 },
509 ]
510}
511
512async fn dispatch_tool(
519 tool_name: &str,
520 arguments: &Value,
521 state: &AppState,
522 user: &AuthenticatedUser,
523) -> Result<ToolResult, RpcError> {
524 let org_id = match user.organization_id {
525 Some(id) => id,
526 None => {
527 return Err(RpcError {
528 code: ErrorCode::INVALID_REQUEST,
529 message: "User does not belong to an organization".to_string(),
530 data: None,
531 })
532 }
533 };
534
535 match tool_name {
536 "list_buildings" => {
537 let page = arguments.get("page").and_then(|v| v.as_u64()).unwrap_or(1) as i64;
538 let per_page = arguments
539 .get("per_page")
540 .and_then(|v| v.as_u64())
541 .unwrap_or(20) as i64;
542
543 let page_request = crate::application::dto::PageRequest {
544 page,
545 per_page,
546 sort_by: None,
547 order: crate::application::dto::SortOrder::default(),
548 };
549 match state
550 .building_use_cases
551 .list_buildings_paginated(&page_request, Some(org_id))
552 .await
553 {
554 Ok((buildings, _total)) => {
555 let text = serde_json::to_string_pretty(&buildings)
556 .unwrap_or_else(|_| "[]".to_string());
557 Ok(ToolResult {
558 content: vec![ContentBlock {
559 content_type: "text".to_string(),
560 text,
561 }],
562 is_error: None,
563 })
564 }
565 Err(e) => Err(RpcError {
566 code: ErrorCode::INTERNAL_ERROR,
567 message: format!("Failed to list buildings: {}", e),
568 data: None,
569 }),
570 }
571 }
572
573 "get_building" => {
574 let building_id_str = arguments
575 .get("building_id")
576 .and_then(|v| v.as_str())
577 .ok_or_else(|| RpcError {
578 code: ErrorCode::INVALID_PARAMS,
579 message: "building_id is required".to_string(),
580 data: None,
581 })?;
582
583 let building_id = Uuid::parse_str(building_id_str).map_err(|_| RpcError {
584 code: ErrorCode::INVALID_PARAMS,
585 message: "building_id must be a valid UUID".to_string(),
586 data: None,
587 })?;
588
589 match state.building_use_cases.get_building(building_id).await {
590 Ok(Some(building)) => {
591 let text = serde_json::to_string_pretty(&building)
592 .unwrap_or_else(|_| "{}".to_string());
593 Ok(ToolResult {
594 content: vec![ContentBlock {
595 content_type: "text".to_string(),
596 text,
597 }],
598 is_error: None,
599 })
600 }
601 Ok(None) => Err(RpcError {
602 code: ErrorCode::INVALID_PARAMS,
603 message: format!("Building not found: {}", building_id),
604 data: None,
605 }),
606 Err(e) => Err(RpcError {
607 code: ErrorCode::INTERNAL_ERROR,
608 message: format!("Failed to get building: {}", e),
609 data: None,
610 }),
611 }
612 }
613
614 "list_owners" => {
615 let _building_id = arguments
616 .get("building_id")
617 .and_then(|v| v.as_str())
618 .and_then(|s| Uuid::parse_str(s).ok());
619
620 let page_request = crate::application::dto::PageRequest {
621 page: 1,
622 per_page: 100,
623 sort_by: None,
624 order: crate::application::dto::SortOrder::default(),
625 };
626 match state
627 .owner_use_cases
628 .list_owners_paginated(&page_request, Some(org_id))
629 .await
630 {
631 Ok((owners, _total)) => {
632 let text =
633 serde_json::to_string_pretty(&owners).unwrap_or_else(|_| "[]".to_string());
634 Ok(ToolResult {
635 content: vec![ContentBlock {
636 content_type: "text".to_string(),
637 text,
638 }],
639 is_error: None,
640 })
641 }
642 Err(e) => Err(RpcError {
643 code: ErrorCode::INTERNAL_ERROR,
644 message: format!("Failed to list owners: {}", e),
645 data: None,
646 }),
647 }
648 }
649
650 "list_meetings" => {
651 let building_id_str = arguments
652 .get("building_id")
653 .and_then(|v| v.as_str())
654 .ok_or_else(|| RpcError {
655 code: ErrorCode::INVALID_PARAMS,
656 message: "building_id is required".to_string(),
657 data: None,
658 })?;
659
660 let building_id = Uuid::parse_str(building_id_str).map_err(|_| RpcError {
661 code: ErrorCode::INVALID_PARAMS,
662 message: "building_id must be a valid UUID".to_string(),
663 data: None,
664 })?;
665
666 match state
667 .meeting_use_cases
668 .list_meetings_by_building(building_id)
669 .await
670 {
671 Ok(meetings) => {
672 let text = serde_json::to_string_pretty(&meetings)
673 .unwrap_or_else(|_| "[]".to_string());
674 Ok(ToolResult {
675 content: vec![ContentBlock {
676 content_type: "text".to_string(),
677 text,
678 }],
679 is_error: None,
680 })
681 }
682 Err(e) => Err(RpcError {
683 code: ErrorCode::INTERNAL_ERROR,
684 message: format!("Failed to list meetings: {}", e),
685 data: None,
686 }),
687 }
688 }
689
690 "get_financial_summary" => {
691 let building_id_str = arguments
692 .get("building_id")
693 .and_then(|v| v.as_str())
694 .ok_or_else(|| RpcError {
695 code: ErrorCode::INVALID_PARAMS,
696 message: "building_id is required".to_string(),
697 data: None,
698 })?;
699
700 let building_id = Uuid::parse_str(building_id_str).map_err(|_| RpcError {
701 code: ErrorCode::INVALID_PARAMS,
702 message: "building_id must be a valid UUID".to_string(),
703 data: None,
704 })?;
705
706 let expenses_result = state
708 .expense_use_cases
709 .list_expenses_by_building(building_id)
710 .await;
711
712 match expenses_result {
713 Ok(expenses) => {
714 use crate::domain::entities::{ApprovalStatus, PaymentStatus};
715
716 let total_expenses: f64 = 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: f64 = contributions.iter().map(|c| c.amount).sum();
815
816 let balance = json!({
817 "owner_id": owner_id,
818 "outstanding_contributions": contributions.len(),
819 "total_due_eur": format!("{:.2}", total_due)
820 });
821
822 let text =
823 serde_json::to_string_pretty(&balance).unwrap_or_else(|_| "{}".to_string());
824 Ok(ToolResult {
825 content: vec![ContentBlock {
826 content_type: "text".to_string(),
827 text,
828 }],
829 is_error: None,
830 })
831 }
832 Err(e) => Err(RpcError {
833 code: ErrorCode::INTERNAL_ERROR,
834 message: format!("Failed to get owner balance: {}", e),
835 data: None,
836 }),
837 }
838 }
839
840 "list_pending_expenses" => {
841 let building_id = arguments
842 .get("building_id")
843 .and_then(|v| v.as_str())
844 .and_then(|s| Uuid::parse_str(s).ok());
845
846 let expenses = if let Some(bid) = building_id {
847 state.expense_use_cases.list_expenses_by_building(bid).await
848 } else {
849 {
850 let page_request = crate::application::dto::PageRequest {
851 page: 1,
852 per_page: 1000,
853 sort_by: None,
854 order: crate::application::dto::SortOrder::default(),
855 };
856 state
857 .expense_use_cases
858 .list_expenses_paginated(&page_request, Some(org_id))
859 .await
860 .map(|(expenses, _total)| expenses)
861 }
862 };
863
864 match expenses {
865 Ok(mut all_expenses) => {
866 use crate::domain::entities::ApprovalStatus as AS;
867 let status_filter = arguments
869 .get("status")
870 .and_then(|v| v.as_str())
871 .unwrap_or("PendingApproval");
872
873 let target_status = match status_filter {
874 "Draft" | "draft" => Some(AS::Draft),
875 "PendingApproval" | "pending_approval" => Some(AS::PendingApproval),
876 "Approved" | "approved" => Some(AS::Approved),
877 "Rejected" | "rejected" => Some(AS::Rejected),
878 _ => None,
879 };
880
881 if let Some(target) = target_status {
882 all_expenses.retain(|e| e.approval_status == target);
883 }
884
885 let text = serde_json::to_string_pretty(&all_expenses)
886 .unwrap_or_else(|_| "[]".to_string());
887 Ok(ToolResult {
888 content: vec![ContentBlock {
889 content_type: "text".to_string(),
890 text,
891 }],
892 is_error: None,
893 })
894 }
895 Err(e) => Err(RpcError {
896 code: ErrorCode::INTERNAL_ERROR,
897 message: format!("Failed to list expenses: {}", e),
898 data: None,
899 }),
900 }
901 }
902
903 "check_quorum" => {
904 let meeting_id_str = arguments
905 .get("meeting_id")
906 .and_then(|v| v.as_str())
907 .ok_or_else(|| RpcError {
908 code: ErrorCode::INVALID_PARAMS,
909 message: "meeting_id is required".to_string(),
910 data: None,
911 })?;
912
913 let meeting_id = Uuid::parse_str(meeting_id_str).map_err(|_| RpcError {
914 code: ErrorCode::INVALID_PARAMS,
915 message: "meeting_id must be a valid UUID".to_string(),
916 data: None,
917 })?;
918
919 match state.meeting_use_cases.get_meeting(meeting_id).await {
920 Ok(Some(meeting)) => {
921 let quorum_ok = meeting.quorum_validated;
922 let pct = meeting.quorum_percentage.unwrap_or(0.0);
923 let total = meeting.total_quotas.unwrap_or(1000.0);
924 let present = meeting.present_quotas.unwrap_or(0.0);
925
926 let result = json!({
927 "meeting_id": meeting_id,
928 "meeting_title": meeting.title,
929 "quorum_validated": quorum_ok,
930 "quorum_percentage": pct,
931 "present_quotas": present,
932 "total_quotas": total,
933 "legal_threshold_pct": 50.0,
934 "legal_basis": "Art. 3.87 §5 Code Civil belge",
935 "status_message": if quorum_ok {
936 format!("✅ Quorum atteint: {:.1}% des tantièmes présents/représentés", pct)
937 } else {
938 format!("❌ Quorum non atteint: {:.1}% des tantièmes présents (minimum 50% requis)", pct)
939 }
940 });
941
942 let text =
943 serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string());
944 Ok(ToolResult {
945 content: vec![ContentBlock {
946 content_type: "text".to_string(),
947 text,
948 }],
949 is_error: None,
950 })
951 }
952 Ok(None) => Err(RpcError {
953 code: ErrorCode::INVALID_PARAMS,
954 message: format!("Meeting not found: {}", meeting_id),
955 data: None,
956 }),
957 Err(e) => Err(RpcError {
958 code: ErrorCode::INTERNAL_ERROR,
959 message: format!("Failed to check quorum: {}", e),
960 data: None,
961 }),
962 }
963 }
964
965 "get_building_documents" => {
966 let building_id_str = arguments
967 .get("building_id")
968 .and_then(|v| v.as_str())
969 .ok_or_else(|| RpcError {
970 code: ErrorCode::INVALID_PARAMS,
971 message: "building_id is required".to_string(),
972 data: None,
973 })?;
974
975 let building_id = Uuid::parse_str(building_id_str).map_err(|_| RpcError {
976 code: ErrorCode::INVALID_PARAMS,
977 message: "building_id must be a valid UUID".to_string(),
978 data: None,
979 })?;
980
981 match state
982 .document_use_cases
983 .list_documents_by_building(building_id)
984 .await
985 {
986 Ok(docs) => {
987 let text =
988 serde_json::to_string_pretty(&docs).unwrap_or_else(|_| "[]".to_string());
989 Ok(ToolResult {
990 content: vec![ContentBlock {
991 content_type: "text".to_string(),
992 text,
993 }],
994 is_error: None,
995 })
996 }
997 Err(e) => Err(RpcError {
998 code: ErrorCode::INTERNAL_ERROR,
999 message: format!("Failed to get documents: {}", e),
1000 data: None,
1001 }),
1002 }
1003 }
1004
1005 "legal_search" => {
1006 let query = arguments
1007 .get("query")
1008 .and_then(|v| v.as_str())
1009 .unwrap_or("")
1010 .to_lowercase();
1011
1012 let legal_base = vec![
1014 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"}),
1015 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"}),
1016 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"}),
1017 json!({"code": "MAJ01", "article": "Art. 3.88 §1 CC", "title": "Majorité simple", "content": "Majorité simple = 50%+1 des votes exprimés", "category": "Majorité"}),
1018 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é"}),
1019 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é"}),
1020 json!({"code": "MAJ04", "article": "Art. 3.88 §3 CC", "title": "Modification statuts", "content": "Modification de statuts requiert 4/5 des tantièmes", "category": "Majorité"}),
1021 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"}),
1022 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"}),
1023 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"}),
1024 ];
1025
1026 let results: Vec<_> = legal_base
1028 .iter()
1029 .filter(|item| {
1030 let title = item
1031 .get("title")
1032 .and_then(|v| v.as_str())
1033 .unwrap_or("")
1034 .to_lowercase();
1035 let content = item
1036 .get("content")
1037 .and_then(|v| v.as_str())
1038 .unwrap_or("")
1039 .to_lowercase();
1040 let code = item
1041 .get("code")
1042 .and_then(|v| v.as_str())
1043 .unwrap_or("")
1044 .to_lowercase();
1045 title.contains(&query) || content.contains(&query) || code.contains(&query)
1046 })
1047 .cloned()
1048 .collect();
1049
1050 let text =
1051 serde_json::to_string_pretty(&json!({"count": results.len(), "results": results}))
1052 .unwrap_or_else(|_| "{}".to_string());
1053 Ok(ToolResult {
1054 content: vec![ContentBlock {
1055 content_type: "text".to_string(),
1056 text,
1057 }],
1058 is_error: None,
1059 })
1060 }
1061
1062 "majority_calculator" => {
1063 let decision_type = arguments
1064 .get("decision_type")
1065 .and_then(|v| v.as_str())
1066 .unwrap_or("ordinary");
1067
1068 let result = match decision_type {
1069 "ordinary" => json!({
1070 "decision_type": "Ordinary",
1071 "majority": "Simple",
1072 "threshold": "50%+1 des votes exprimés",
1073 "percentage": 50.5,
1074 "article": "Art. 3.88 §1 CC",
1075 "examples": ["Approbation budget", "Approbation charges", "Élection syndic"]
1076 }),
1077 "works_simple" => json!({
1078 "decision_type": "Works (simple)",
1079 "majority": "Absolute",
1080 "threshold": "Majorité absolue (>50% de tous les copropriétaires)",
1081 "percentage": 50.1,
1082 "article": "Art. 3.88 §2 1° CC",
1083 "examples": ["Travaux ordinaires > 5000€", "Amélioration commune"],
1084 "requirements": ["Minimum 3 devis", "Approbation en AG"]
1085 }),
1086 "works_heavy" => json!({
1087 "decision_type": "Works (heavy)",
1088 "majority": "Two-thirds",
1089 "threshold": "2/3 des tantièmes",
1090 "percentage": 66.7,
1091 "article": "Art. 3.88 §2 4° CC",
1092 "examples": ["Travaux de structure", "Remplacement toit/façade", "Travaux de sécurité"],
1093 "requirements": ["Étude technique", "Plusieurs devis", "Enquête copropriétaires"]
1094 }),
1095 "statute_change" => json!({
1096 "decision_type": "Statute change",
1097 "majority": "Four-fifths",
1098 "threshold": "4/5 des tantièmes",
1099 "percentage": 80.0,
1100 "article": "Art. 3.88 §3 CC",
1101 "examples": ["Modification règlement", "Changement gestion syndicale"]
1102 }),
1103 "unanimity" => json!({
1104 "decision_type": "Special",
1105 "majority": "Unanimity",
1106 "threshold": "Unanimité de tous les copropriétaires",
1107 "percentage": 100.0,
1108 "article": "Art. 3.88 §4 CC",
1109 "examples": ["Division/fusion lots"]
1110 }),
1111 _ => json!({
1112 "decision_type": "Unknown",
1113 "majority": "Simple",
1114 "threshold": "50%+1",
1115 "article": "Art. 3.88 §1 CC"
1116 }),
1117 };
1118
1119 let text = serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string());
1120 Ok(ToolResult {
1121 content: vec![ContentBlock {
1122 content_type: "text".to_string(),
1123 text,
1124 }],
1125 is_error: None,
1126 })
1127 }
1128
1129 "list_owners_of_building" => {
1130 let building_id_str = arguments
1131 .get("building_id")
1132 .and_then(|v| v.as_str())
1133 .ok_or_else(|| RpcError {
1134 code: ErrorCode::INVALID_PARAMS,
1135 message: "building_id is required".to_string(),
1136 data: None,
1137 })?;
1138
1139 let _building_id = Uuid::parse_str(building_id_str).map_err(|_| RpcError {
1140 code: ErrorCode::INVALID_PARAMS,
1141 message: "building_id must be a valid UUID".to_string(),
1142 data: None,
1143 })?;
1144
1145 let page_request = crate::application::dto::PageRequest {
1146 page: 1,
1147 per_page: 100,
1148 sort_by: None,
1149 order: crate::application::dto::SortOrder::default(),
1150 };
1151 match state
1152 .owner_use_cases
1153 .list_owners_paginated(&page_request, Some(org_id))
1154 .await
1155 {
1156 Ok((owners, _total)) => {
1157 let text =
1158 serde_json::to_string_pretty(&owners).unwrap_or_else(|_| "[]".to_string());
1159 Ok(ToolResult {
1160 content: vec![ContentBlock {
1161 content_type: "text".to_string(),
1162 text,
1163 }],
1164 is_error: None,
1165 })
1166 }
1167 Err(e) => Err(RpcError {
1168 code: ErrorCode::INTERNAL_ERROR,
1169 message: format!("Failed to list building owners: {}", e),
1170 data: None,
1171 }),
1172 }
1173 }
1174
1175 "ag_quorum_check" => {
1176 let meeting_id_str = arguments
1177 .get("meeting_id")
1178 .and_then(|v| v.as_str())
1179 .ok_or_else(|| RpcError {
1180 code: ErrorCode::INVALID_PARAMS,
1181 message: "meeting_id is required".to_string(),
1182 data: None,
1183 })?;
1184
1185 let meeting_id = Uuid::parse_str(meeting_id_str).map_err(|_| RpcError {
1186 code: ErrorCode::INVALID_PARAMS,
1187 message: "meeting_id must be a valid UUID".to_string(),
1188 data: None,
1189 })?;
1190
1191 match state.meeting_use_cases.get_meeting(meeting_id).await {
1192 Ok(Some(meeting)) => {
1193 let quorum_ok = meeting.quorum_validated;
1194 let pct = meeting.quorum_percentage.unwrap_or(0.0);
1195
1196 let result = if quorum_ok {
1197 json!({
1198 "meeting_id": meeting_id,
1199 "quorum_validated": true,
1200 "quorum_percentage": pct,
1201 "status": "Quorum atteint",
1202 "message": format!("✅ Quorum validé: {:.1}% des tantièmes présents/représentés", pct),
1203 "next_steps": "L'AG peut délibérer valablement selon Art. 3.87 §5 CC"
1204 })
1205 } else {
1206 json!({
1207 "meeting_id": meeting_id,
1208 "quorum_validated": false,
1209 "quorum_percentage": pct,
1210 "status": "Quorum insuffisant",
1211 "message": format!("❌ Quorum non atteint: {:.1}% (minimum 50% requis)", pct),
1212 "legal_basis": "Art. 3.87 §3-4 CC - Procédure de 2e convocation",
1213 "next_steps": [
1214 "1. Convoquer une 2e AG dans les 15 jours",
1215 "2. Respecter délai minimum de 15 jours avant date de réunion",
1216 "3. À la 2e convocation, quorum requis: au moins 1/4 des tantièmes",
1217 "4. Si toujours insuffisant: peut délibérer quel que soit quorum"
1218 ]
1219 })
1220 };
1221
1222 let text =
1223 serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string());
1224 Ok(ToolResult {
1225 content: vec![ContentBlock {
1226 content_type: "text".to_string(),
1227 text,
1228 }],
1229 is_error: None,
1230 })
1231 }
1232 Ok(None) => Err(RpcError {
1233 code: ErrorCode::INVALID_PARAMS,
1234 message: format!("Meeting not found: {}", meeting_id),
1235 data: None,
1236 }),
1237 Err(e) => Err(RpcError {
1238 code: ErrorCode::INTERNAL_ERROR,
1239 message: format!("Failed to check AG quorum: {}", e),
1240 data: None,
1241 }),
1242 }
1243 }
1244
1245 "ag_vote" => {
1246 let resolution_id_str = arguments
1247 .get("resolution_id")
1248 .and_then(|v| v.as_str())
1249 .ok_or_else(|| RpcError {
1250 code: ErrorCode::INVALID_PARAMS,
1251 message: "resolution_id is required".to_string(),
1252 data: None,
1253 })?;
1254
1255 let choice_str = arguments
1256 .get("choice")
1257 .and_then(|v| v.as_str())
1258 .ok_or_else(|| RpcError {
1259 code: ErrorCode::INVALID_PARAMS,
1260 message: "choice is required (Pour/Contre/Abstention)".to_string(),
1261 data: None,
1262 })?;
1263
1264 let resolution_id = Uuid::parse_str(resolution_id_str).map_err(|_| RpcError {
1265 code: ErrorCode::INVALID_PARAMS,
1266 message: "resolution_id must be a valid UUID".to_string(),
1267 data: None,
1268 })?;
1269
1270 let result = json!({
1273 "resolution_id": resolution_id,
1274 "choice": choice_str,
1275 "status": "vote_recorded",
1276 "message": format!("Vote pour '{}' enregistré avec succès", choice_str),
1277 "note": "Vote final enregistré au fermeture de scrutin par le syndic"
1278 });
1279
1280 let text = serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string());
1281 Ok(ToolResult {
1282 content: vec![ContentBlock {
1283 content_type: "text".to_string(),
1284 text,
1285 }],
1286 is_error: None,
1287 })
1288 }
1289
1290 "comptabilite_situation" => {
1291 let building_id_str = arguments
1292 .get("building_id")
1293 .and_then(|v| v.as_str())
1294 .ok_or_else(|| RpcError {
1295 code: ErrorCode::INVALID_PARAMS,
1296 message: "building_id is required".to_string(),
1297 data: None,
1298 })?;
1299
1300 let building_id = Uuid::parse_str(building_id_str).map_err(|_| RpcError {
1301 code: ErrorCode::INVALID_PARAMS,
1302 message: "building_id must be a valid UUID".to_string(),
1303 data: None,
1304 })?;
1305
1306 match state
1307 .expense_use_cases
1308 .list_expenses_by_building(building_id)
1309 .await
1310 {
1311 Ok(expenses) => {
1312 use crate::domain::entities::{ApprovalStatus, PaymentStatus};
1313
1314 let total_expenses: f64 = expenses
1315 .iter()
1316 .filter(|e| e.approval_status == ApprovalStatus::Approved)
1317 .map(|e| e.amount)
1318 .sum();
1319
1320 let outstanding: f64 = expenses
1321 .iter()
1322 .filter(|e| e.payment_status != PaymentStatus::Paid)
1323 .map(|e| e.amount)
1324 .sum();
1325
1326 let situation = json!({
1327 "building_id": building_id,
1328 "total_expenses_approved_eur": format!("{:.2}", total_expenses),
1329 "outstanding_eur": format!("{:.2}", outstanding),
1330 "expense_count": expenses.len(),
1331 "paid_count": expenses.iter().filter(|e| e.payment_status == PaymentStatus::Paid).count(),
1332 "pending_count": expenses.iter().filter(|e| e.approval_status == ApprovalStatus::PendingApproval).count()
1333 });
1334
1335 let text = serde_json::to_string_pretty(&situation)
1336 .unwrap_or_else(|_| "{}".to_string());
1337 Ok(ToolResult {
1338 content: vec![ContentBlock {
1339 content_type: "text".to_string(),
1340 text,
1341 }],
1342 is_error: None,
1343 })
1344 }
1345 Err(e) => Err(RpcError {
1346 code: ErrorCode::INTERNAL_ERROR,
1347 message: format!("Failed to get comptabilite situation: {}", e),
1348 data: None,
1349 }),
1350 }
1351 }
1352
1353 "appel_de_fonds" => {
1354 let building_id_str = arguments
1355 .get("building_id")
1356 .and_then(|v| v.as_str())
1357 .ok_or_else(|| RpcError {
1358 code: ErrorCode::INVALID_PARAMS,
1359 message: "building_id is required".to_string(),
1360 data: None,
1361 })?;
1362
1363 let amount_cents = arguments
1364 .get("amount_cents")
1365 .and_then(|v| v.as_i64())
1366 .ok_or_else(|| RpcError {
1367 code: ErrorCode::INVALID_PARAMS,
1368 message: "amount_cents is required".to_string(),
1369 data: None,
1370 })?;
1371
1372 let due_date = arguments
1373 .get("due_date")
1374 .and_then(|v| v.as_str())
1375 .ok_or_else(|| RpcError {
1376 code: ErrorCode::INVALID_PARAMS,
1377 message: "due_date is required (YYYY-MM-DD)".to_string(),
1378 data: None,
1379 })?;
1380
1381 let description = arguments
1382 .get("description")
1383 .and_then(|v| v.as_str())
1384 .unwrap_or("Appel de fonds extraordinaires");
1385
1386 let _building_id = Uuid::parse_str(building_id_str).map_err(|_| RpcError {
1387 code: ErrorCode::INVALID_PARAMS,
1388 message: "building_id must be a valid UUID".to_string(),
1389 data: None,
1390 })?;
1391
1392 let result = json!({
1393 "status": "pending_creation",
1394 "building_id": building_id_str,
1395 "amount_cents": amount_cents,
1396 "amount_eur": format!("{:.2}", amount_cents as f64 / 100.0),
1397 "due_date": due_date,
1398 "description": description,
1399 "message": "Appel de fonds enregistré. Les propriétaires recevront notification via leurs contacts enregistrés.",
1400 "next_step": "Vérifier les coordonnées email de tous les copropriétaires avant envoi"
1401 });
1402
1403 let text = serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string());
1404 Ok(ToolResult {
1405 content: vec![ContentBlock {
1406 content_type: "text".to_string(),
1407 text,
1408 }],
1409 is_error: None,
1410 })
1411 }
1412
1413 "travaux_qualifier" => {
1414 let description = arguments
1415 .get("description")
1416 .and_then(|v| v.as_str())
1417 .ok_or_else(|| RpcError {
1418 code: ErrorCode::INVALID_PARAMS,
1419 message: "description is required".to_string(),
1420 data: None,
1421 })?;
1422
1423 let estimated_amount_eur = arguments
1424 .get("estimated_amount_eur")
1425 .and_then(|v| v.as_f64())
1426 .unwrap_or(0.0);
1427
1428 let is_emergency = arguments
1429 .get("is_emergency")
1430 .and_then(|v| v.as_bool())
1431 .unwrap_or(false);
1432
1433 let result = if is_emergency {
1434 json!({
1435 "description": description,
1436 "amount_eur": format!("{:.2}", estimated_amount_eur),
1437 "qualification": "Travaux d'urgence / Conservatoires",
1438 "syndic_can_act_alone": true,
1439 "requires_ag_approval": false,
1440 "legal_basis": "Art. 3.89 §5 2° CC",
1441 "requirements": [
1442 "Documentation du caractère urgent",
1443 "Justification conservatoire",
1444 "Rapport aux copropriétaires postérieurement"
1445 ]
1446 })
1447 } else if estimated_amount_eur > 5000.0 {
1448 json!({
1449 "description": description,
1450 "amount_eur": format!("{:.2}", estimated_amount_eur),
1451 "qualification": "Travaux non-urgents > 5000€",
1452 "syndic_can_act_alone": false,
1453 "requires_ag_approval": true,
1454 "majority_required": "Majorité absolue (Art. 3.88 §2 1° CC)",
1455 "legal_requirements": [
1456 "Minimum 3 devis concurrentiels",
1457 "Rapport comparatif syndic",
1458 "Vote en assemblée générale",
1459 "Delai: approbation dans 3 mois après vote"
1460 ],
1461 "three_quotes_mandatory": true
1462 })
1463 } else {
1464 json!({
1465 "description": description,
1466 "amount_eur": format!("{:.2}", estimated_amount_eur),
1467 "qualification": "Travaux ordinaires / Entretien",
1468 "syndic_can_act_alone": true,
1469 "requires_ag_approval": false,
1470 "legal_basis": "Art. 3.89 §5 CC",
1471 "requirements": [
1472 "Entrée budgétaire 'Entretien/Réparations'",
1473 "Documentation des trois devis souhaitable"
1474 ]
1475 })
1476 };
1477
1478 let text = serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string());
1479 Ok(ToolResult {
1480 content: vec![ContentBlock {
1481 content_type: "text".to_string(),
1482 text,
1483 }],
1484 is_error: None,
1485 })
1486 }
1487
1488 "alertes_list" => {
1489 let building_id = arguments
1490 .get("building_id")
1491 .and_then(|v| v.as_str())
1492 .and_then(|s| Uuid::parse_str(s).ok());
1493
1494 let mut alerts = Vec::new();
1496
1497 if let Some(bid) = building_id {
1498 use crate::domain::entities::meeting::MeetingStatus;
1500 if let Ok(meetings) = state.meeting_use_cases.list_meetings_by_building(bid).await {
1501 for meeting in meetings {
1502 if meeting.status == MeetingStatus::Completed {
1503 alerts.push(json!({
1504 "type": "MINUTES_MISSING",
1505 "severity": "high",
1506 "title": "PV d'AG non envoyé",
1507 "message": format!("AG du {} sans minutes publiées", meeting.title),
1508 "action": "Envoyer le PV aux copropriétaires"
1509 }));
1510 }
1511 }
1512 }
1513 }
1514
1515 alerts.push(json!({
1517 "type": "LEGAL_REMINDER",
1518 "severity": "info",
1519 "title": "Rappel conformité légale",
1520 "message": "Vérifier les délais légaux pour convocations AG (15 jours minimum Art. 3.87 §1 CC)",
1521 "legal_basis": "Code Civil Belge"
1522 }));
1523
1524 let result = json!({
1525 "building_id": building_id,
1526 "alert_count": alerts.len(),
1527 "alerts": alerts
1528 });
1529
1530 let text = serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string());
1531 Ok(ToolResult {
1532 content: vec![ContentBlock {
1533 content_type: "text".to_string(),
1534 text,
1535 }],
1536 is_error: None,
1537 })
1538 }
1539
1540 "energie_campagne_list" => {
1541 let status_filter = arguments
1542 .get("status")
1543 .and_then(|v| v.as_str())
1544 .unwrap_or("");
1545
1546 let campaigns = json!([
1548 {
1549 "id": "camp-2024-001",
1550 "name": "Achat groupé électricité 2024",
1551 "status": "Active",
1552 "start_date": "2024-01-01",
1553 "end_date": "2024-12-31",
1554 "participants": 15,
1555 "anonymized_avg_consumption": "~3500 kWh/an",
1556 "estimated_savings_pct": 12
1557 }
1558 ]);
1559
1560 let result = json!({
1561 "status_filter": status_filter,
1562 "campaign_count": campaigns.as_array().map(|a| a.len()).unwrap_or(0),
1563 "campaigns": campaigns
1564 });
1565
1566 let text = serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string());
1567 Ok(ToolResult {
1568 content: vec![ContentBlock {
1569 content_type: "text".to_string(),
1570 text,
1571 }],
1572 is_error: None,
1573 })
1574 }
1575
1576 _ => Err(RpcError {
1577 code: ErrorCode::METHOD_NOT_FOUND,
1578 message: format!("Unknown tool: {}", tool_name),
1579 data: Some(json!({
1580 "available_tools": get_mcp_tools().iter().map(|t| &t.name).collect::<Vec<_>>()
1581 })),
1582 }),
1583 }
1584}
1585
1586async fn handle_jsonrpc(req: JsonRpcRequest, state: &AppState, user: &AuthenticatedUser) -> Value {
1592 let id = req.id.clone();
1593
1594 if req.jsonrpc != "2.0" {
1595 return serde_json::to_value(JsonRpcError {
1596 jsonrpc: "2.0".to_string(),
1597 id,
1598 error: RpcError {
1599 code: ErrorCode::INVALID_REQUEST,
1600 message: "jsonrpc must be '2.0'".to_string(),
1601 data: None,
1602 },
1603 })
1604 .unwrap_or(json!({"error": "serialization error"}));
1605 }
1606
1607 match req.method.as_str() {
1608 "initialize" => {
1610 let client_info = req
1611 .params
1612 .as_ref()
1613 .and_then(|p| p.get("clientInfo"))
1614 .cloned()
1615 .unwrap_or(json!({}));
1616
1617 let result = json!({
1618 "protocolVersion": "2024-11-05",
1619 "capabilities": {
1620 "tools": { "listChanged": false }
1621 },
1622 "serverInfo": {
1623 "name": "koprogo-mcp",
1624 "version": env!("CARGO_PKG_VERSION")
1625 },
1626 "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."
1627 });
1628
1629 tracing::info!(
1630 method = "initialize",
1631 client_info = ?client_info,
1632 user_id = ?user.user_id,
1633 "MCP session initialized"
1634 );
1635
1636 serde_json::to_value(JsonRpcResponse {
1637 jsonrpc: "2.0".to_string(),
1638 id,
1639 result,
1640 })
1641 .unwrap_or(json!({"error": "serialization error"}))
1642 }
1643
1644 "notifications/initialized" => {
1646 json!(null) }
1648
1649 "tools/list" => {
1651 let tools = get_mcp_tools();
1652 let result = json!({ "tools": tools });
1653
1654 serde_json::to_value(JsonRpcResponse {
1655 jsonrpc: "2.0".to_string(),
1656 id,
1657 result,
1658 })
1659 .unwrap_or(json!({"error": "serialization error"}))
1660 }
1661
1662 "tools/call" => {
1664 let params = match req.params {
1665 Some(p) => p,
1666 None => {
1667 return serde_json::to_value(JsonRpcError {
1668 jsonrpc: "2.0".to_string(),
1669 id,
1670 error: RpcError {
1671 code: ErrorCode::INVALID_PARAMS,
1672 message: "params are required for tools/call".to_string(),
1673 data: None,
1674 },
1675 })
1676 .unwrap_or(json!({"error": "serialization error"}));
1677 }
1678 };
1679
1680 let tool_name = match params.get("name").and_then(|v| v.as_str()) {
1681 Some(n) => n.to_string(),
1682 None => {
1683 return serde_json::to_value(JsonRpcError {
1684 jsonrpc: "2.0".to_string(),
1685 id,
1686 error: RpcError {
1687 code: ErrorCode::INVALID_PARAMS,
1688 message: "params.name is required".to_string(),
1689 data: None,
1690 },
1691 })
1692 .unwrap_or(json!({"error": "serialization error"}));
1693 }
1694 };
1695
1696 let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
1697
1698 tracing::info!(
1699 method = "tools/call",
1700 tool = %tool_name,
1701 user_id = ?user.user_id,
1702 "MCP tool called"
1703 );
1704
1705 match dispatch_tool(&tool_name, &arguments, state, user).await {
1706 Ok(tool_result) => serde_json::to_value(JsonRpcResponse {
1707 jsonrpc: "2.0".to_string(),
1708 id,
1709 result: serde_json::to_value(tool_result).unwrap_or(json!({})),
1710 })
1711 .unwrap_or(json!({"error": "serialization error"})),
1712 Err(err) => serde_json::to_value(JsonRpcError {
1713 jsonrpc: "2.0".to_string(),
1714 id,
1715 error: err,
1716 })
1717 .unwrap_or(json!({"error": "serialization error"})),
1718 }
1719 }
1720
1721 "ping" => serde_json::to_value(JsonRpcResponse {
1723 jsonrpc: "2.0".to_string(),
1724 id,
1725 result: json!({}),
1726 })
1727 .unwrap_or(json!({"error": "serialization error"})),
1728
1729 _ => serde_json::to_value(JsonRpcError {
1731 jsonrpc: "2.0".to_string(),
1732 id,
1733 error: RpcError {
1734 code: ErrorCode::METHOD_NOT_FOUND,
1735 message: format!("Method not found: {}", req.method),
1736 data: None,
1737 },
1738 })
1739 .unwrap_or(json!({"error": "serialization error"})),
1740 }
1741}
1742
1743#[get("/mcp/sse")]
1755pub async fn mcp_sse_endpoint(
1756 _req: HttpRequest,
1757 claims: AuthenticatedUser,
1758 _state: Data<Arc<AppState>>,
1759) -> HttpResponse {
1760 let session_id = Uuid::new_v4();
1762 let messages_url = format!("/mcp/messages?session_id={}", session_id);
1763
1764 tracing::info!(
1765 session_id = %session_id,
1766 user_id = %claims.user_id,
1767 "New MCP SSE connection established"
1768 );
1769
1770 let sse_stream = stream::once(async move {
1773 let endpoint_event = format!(
1775 "event: endpoint\ndata: {}\n\n",
1776 serde_json::to_string(&messages_url)
1777 .unwrap_or_else(|_| format!("\"{}\"", messages_url))
1778 );
1779 Ok::<_, actix_web::Error>(actix_web::web::Bytes::from(endpoint_event))
1780 });
1781
1782 HttpResponse::Ok()
1783 .content_type("text/event-stream")
1784 .insert_header(("Cache-Control", "no-cache"))
1785 .insert_header(("X-Accel-Buffering", "no")) .insert_header(("Connection", "keep-alive"))
1787 .streaming(sse_stream)
1788}
1789
1790#[post("/mcp/messages")]
1801pub async fn mcp_messages_endpoint(
1802 req: HttpRequest,
1803 claims: AuthenticatedUser,
1804 state: Data<Arc<AppState>>,
1805 body: web::Json<Value>,
1806) -> HttpResponse {
1807 let session_id = req
1808 .uri()
1809 .query()
1810 .and_then(|q| {
1811 q.split('&')
1812 .find(|p| p.starts_with("session_id="))
1813 .map(|p| &p["session_id=".len()..])
1814 })
1815 .unwrap_or("unknown");
1816
1817 tracing::debug!(
1818 session_id = %session_id,
1819 user_id = %claims.user_id,
1820 "Received MCP message"
1821 );
1822
1823 let body_value = body.into_inner();
1824
1825 if let Some(batch) = body_value.as_array() {
1827 let mut responses = Vec::new();
1828 for item in batch {
1829 match serde_json::from_value::<JsonRpcRequest>(item.clone()) {
1830 Ok(rpc_req) => {
1831 let resp = handle_jsonrpc(rpc_req, &state, &claims).await;
1832 if !resp.is_null() {
1833 responses.push(resp);
1834 }
1835 }
1836 Err(e) => {
1837 responses.push(json!({
1838 "jsonrpc": "2.0",
1839 "id": null,
1840 "error": {
1841 "code": ErrorCode::PARSE_ERROR,
1842 "message": format!("Parse error: {}", e)
1843 }
1844 }));
1845 }
1846 }
1847 }
1848
1849 if responses.is_empty() {
1850 return HttpResponse::NoContent().finish();
1852 }
1853
1854 return HttpResponse::Ok()
1855 .content_type("application/json")
1856 .json(Value::Array(responses));
1857 }
1858
1859 match serde_json::from_value::<JsonRpcRequest>(body_value) {
1861 Ok(rpc_req) => {
1862 let response = handle_jsonrpc(rpc_req, &state, &claims).await;
1863
1864 if response.is_null() {
1865 HttpResponse::NoContent().finish()
1867 } else {
1868 HttpResponse::Ok()
1869 .content_type("application/json")
1870 .json(response)
1871 }
1872 }
1873 Err(e) => HttpResponse::BadRequest()
1874 .content_type("application/json")
1875 .json(json!({
1876 "jsonrpc": "2.0",
1877 "id": null,
1878 "error": {
1879 "code": ErrorCode::PARSE_ERROR,
1880 "message": format!("Parse error: {}", e)
1881 }
1882 })),
1883 }
1884}
1885
1886#[get("/mcp/info")]
1892pub async fn mcp_info_endpoint() -> HttpResponse {
1893 HttpResponse::Ok().json(json!({
1894 "name": "koprogo-mcp",
1895 "version": env!("CARGO_PKG_VERSION"),
1896 "protocol": "MCP/2024-11-05",
1897 "transport": "SSE+HTTP",
1898 "endpoints": {
1899 "sse": "/mcp/sse",
1900 "messages": "/mcp/messages",
1901 "system_prompt": "/mcp/system-prompt",
1902 "legal_index": "/mcp/legal-index"
1903 },
1904 "tools_count": get_mcp_tools().len(),
1905 "description": "Model Context Protocol server for KoproGo — Belgian property management SaaS"
1906 }))
1907}
1908
1909#[get("/mcp/system-prompt")]
1914pub async fn mcp_system_prompt_endpoint(
1915 _claims: AuthenticatedUser,
1916 _state: Data<Arc<AppState>>,
1917) -> HttpResponse {
1918 let prompt = include_str!("../../mcp_system_prompt.md");
1919 HttpResponse::Ok()
1920 .content_type("text/markdown; charset=utf-8")
1921 .body(prompt)
1922}
1923
1924#[get("/mcp/legal-index")]
1929pub async fn mcp_legal_index_endpoint(
1930 _claims: AuthenticatedUser,
1931 _state: Data<Arc<AppState>>,
1932) -> HttpResponse {
1933 let index = include_str!("../../legal_index.json");
1934 HttpResponse::Ok()
1935 .content_type("application/json; charset=utf-8")
1936 .body(index)
1937}
1938
1939#[cfg(test)]
1944mod tests {
1945 use super::*;
1946
1947 #[test]
1948 fn test_mcp_tools_have_unique_names() {
1949 let tools = get_mcp_tools();
1950 let mut names = std::collections::HashSet::new();
1951 for tool in &tools {
1952 assert!(
1953 names.insert(&tool.name),
1954 "Duplicate tool name: {}",
1955 tool.name
1956 );
1957 }
1958 }
1959
1960 #[test]
1961 fn test_mcp_tools_have_required_input_schema_fields() {
1962 let tools = get_mcp_tools();
1963 for tool in &tools {
1964 assert!(!tool.name.is_empty(), "Tool has empty name");
1965 assert!(
1966 !tool.description.is_empty(),
1967 "Tool '{}' has empty description",
1968 tool.name
1969 );
1970 assert!(
1971 tool.input_schema.get("type").is_some(),
1972 "Tool '{}' input_schema missing 'type' field",
1973 tool.name
1974 );
1975 assert!(
1976 tool.input_schema.get("properties").is_some(),
1977 "Tool '{}' input_schema missing 'properties' field",
1978 tool.name
1979 );
1980 }
1981 }
1982
1983 #[test]
1984 fn test_jsonrpc_ping_method() {
1985 let ping_req = JsonRpcRequest {
1987 jsonrpc: "2.0".to_string(),
1988 id: Some(json!(1)),
1989 method: "ping".to_string(),
1990 params: None,
1991 };
1992 assert_eq!(ping_req.method, "ping");
1993 assert_eq!(ping_req.jsonrpc, "2.0");
1994 }
1995
1996 #[test]
1997 fn test_error_codes_are_correct() {
1998 assert_eq!(ErrorCode::PARSE_ERROR, -32700);
1999 assert_eq!(ErrorCode::INVALID_REQUEST, -32600);
2000 assert_eq!(ErrorCode::METHOD_NOT_FOUND, -32601);
2001 assert_eq!(ErrorCode::INVALID_PARAMS, -32602);
2002 assert_eq!(ErrorCode::INTERNAL_ERROR, -32603);
2003 }
2004
2005 #[test]
2006 fn test_tool_count() {
2007 let tools = get_mcp_tools();
2009 assert_eq!(tools.len(), 20, "Expected 20 MCP tools");
2010 }
2011}