Skip to main content

zeroclaw_tools/microsoft365/
mod.rs

1//! Microsoft 365 integration tool — Graph API access for Mail, Teams, Calendar,
2//! OneDrive, and SharePoint via a single action-dispatched tool surface.
3//!
4//! Auth is handled through direct HTTP calls to the Microsoft identity platform
5//! (client credentials or device code flow) with token caching.
6
7pub mod auth;
8pub mod graph_client;
9pub mod types;
10
11use async_trait::async_trait;
12use serde_json::json;
13use std::sync::Arc;
14use zeroclaw_api::tool::{Tool, ToolResult};
15use zeroclaw_config::policy::SecurityPolicy;
16use zeroclaw_config::policy::ToolOperation;
17
18/// Maximum download size for OneDrive files (10 MB).
19const MAX_ONEDRIVE_DOWNLOAD_SIZE: usize = 10 * 1024 * 1024;
20
21/// Default number of items to return in list operations.
22const DEFAULT_TOP: u32 = 25;
23
24pub struct Microsoft365Tool {
25    config: types::Microsoft365ResolvedConfig,
26    security: Arc<SecurityPolicy>,
27    token_cache: Arc<auth::TokenCache>,
28    http_client: reqwest::Client,
29}
30
31impl Microsoft365Tool {
32    pub fn new(
33        config: types::Microsoft365ResolvedConfig,
34        security: Arc<SecurityPolicy>,
35        zeroclaw_dir: &std::path::Path,
36    ) -> anyhow::Result<Self> {
37        let http_client = zeroclaw_config::schema::build_runtime_proxy_client_with_timeouts(
38            "tool.microsoft365",
39            60,
40            10,
41        );
42        let token_cache = Arc::new(auth::TokenCache::new(config.clone(), zeroclaw_dir)?);
43        Ok(Self {
44            config,
45            security,
46            token_cache,
47            http_client,
48        })
49    }
50
51    async fn get_token(&self) -> anyhow::Result<String> {
52        self.token_cache.get_token(&self.http_client).await
53    }
54
55    fn user_id(&self) -> &str {
56        &self.config.user_id
57    }
58
59    async fn dispatch(&self, action: &str, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
60        match action {
61            "mail_list" => self.handle_mail_list(args).await,
62            "mail_send" => self.handle_mail_send(args).await,
63            "teams_message_list" => self.handle_teams_message_list(args).await,
64            "teams_message_send" => self.handle_teams_message_send(args).await,
65            "calendar_events_list" => self.handle_calendar_events_list(args).await,
66            "calendar_event_create" => self.handle_calendar_event_create(args).await,
67            "calendar_event_delete" => self.handle_calendar_event_delete(args).await,
68            "onedrive_list" => self.handle_onedrive_list(args).await,
69            "onedrive_download" => self.handle_onedrive_download(args).await,
70            "sharepoint_search" => self.handle_sharepoint_search(args).await,
71            _ => Ok(ToolResult {
72                success: false,
73                output: String::new(),
74                error: Some(format!("Unknown action: {action}")),
75            }),
76        }
77    }
78
79    // ── Read actions ────────────────────────────────────────────────
80
81    async fn handle_mail_list(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
82        self.security
83            .enforce_tool_operation(ToolOperation::Read, "microsoft365.mail_list")
84            .map_err(|e| {
85                ::zeroclaw_log::record!(
86                    ERROR,
87                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
88                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
89                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
90                    "microsoft365: tool operation denied by policy"
91                );
92                anyhow::Error::msg(e.to_string())
93            })?;
94
95        let token = self.get_token().await?;
96        let folder = args["folder"].as_str();
97        let top = u32::try_from(args["top"].as_u64().unwrap_or(u64::from(DEFAULT_TOP)))
98            .unwrap_or(DEFAULT_TOP);
99
100        let result =
101            graph_client::mail_list(&self.http_client, &token, self.user_id(), folder, top).await?;
102
103        Ok(ToolResult {
104            success: true,
105            output: serde_json::to_string_pretty(&result)?,
106            error: None,
107        })
108    }
109
110    async fn handle_teams_message_list(
111        &self,
112        args: &serde_json::Value,
113    ) -> anyhow::Result<ToolResult> {
114        self.security
115            .enforce_tool_operation(ToolOperation::Read, "microsoft365.teams_message_list")
116            .map_err(|e| {
117                ::zeroclaw_log::record!(
118                    ERROR,
119                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
120                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
121                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
122                    "microsoft365: tool operation denied by policy"
123                );
124                anyhow::Error::msg(e.to_string())
125            })?;
126
127        let token = self.get_token().await?;
128        let team_id = args["team_id"].as_str().ok_or_else(|| {
129            ::zeroclaw_log::record!(
130                WARN,
131                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
132                    .with_outcome(::zeroclaw_log::EventOutcome::Failure),
133                "mod: team_id is required"
134            );
135            anyhow::Error::msg("team_id is required")
136        })?;
137        let channel_id = args["channel_id"].as_str().ok_or_else(|| {
138            ::zeroclaw_log::record!(
139                WARN,
140                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
141                    .with_outcome(::zeroclaw_log::EventOutcome::Failure),
142                "mod: channel_id is required"
143            );
144            anyhow::Error::msg("channel_id is required")
145        })?;
146        let top = u32::try_from(args["top"].as_u64().unwrap_or(u64::from(DEFAULT_TOP)))
147            .unwrap_or(DEFAULT_TOP);
148
149        let result =
150            graph_client::teams_message_list(&self.http_client, &token, team_id, channel_id, top)
151                .await?;
152
153        Ok(ToolResult {
154            success: true,
155            output: serde_json::to_string_pretty(&result)?,
156            error: None,
157        })
158    }
159
160    async fn handle_calendar_events_list(
161        &self,
162        args: &serde_json::Value,
163    ) -> anyhow::Result<ToolResult> {
164        self.security
165            .enforce_tool_operation(ToolOperation::Read, "microsoft365.calendar_events_list")
166            .map_err(|e| {
167                ::zeroclaw_log::record!(
168                    ERROR,
169                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
170                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
171                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
172                    "microsoft365: tool operation denied by policy"
173                );
174                anyhow::Error::msg(e.to_string())
175            })?;
176
177        let token = self.get_token().await?;
178        let start = args["start"].as_str().ok_or_else(|| {
179            ::zeroclaw_log::record!(
180                WARN,
181                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
182                    .with_outcome(::zeroclaw_log::EventOutcome::Failure),
183                "mod: start datetime is required"
184            );
185            anyhow::Error::msg("start datetime is required")
186        })?;
187        let end = args["end"].as_str().ok_or_else(|| {
188            ::zeroclaw_log::record!(
189                WARN,
190                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
191                    .with_outcome(::zeroclaw_log::EventOutcome::Failure),
192                "mod: end datetime is required"
193            );
194            anyhow::Error::msg("end datetime is required")
195        })?;
196        let top = u32::try_from(args["top"].as_u64().unwrap_or(u64::from(DEFAULT_TOP)))
197            .unwrap_or(DEFAULT_TOP);
198
199        let result = graph_client::calendar_events_list(
200            &self.http_client,
201            &token,
202            self.user_id(),
203            start,
204            end,
205            top,
206        )
207        .await?;
208
209        Ok(ToolResult {
210            success: true,
211            output: serde_json::to_string_pretty(&result)?,
212            error: None,
213        })
214    }
215
216    async fn handle_onedrive_list(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
217        self.security
218            .enforce_tool_operation(ToolOperation::Read, "microsoft365.onedrive_list")
219            .map_err(|e| {
220                ::zeroclaw_log::record!(
221                    ERROR,
222                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
223                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
224                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
225                    "microsoft365: tool operation denied by policy"
226                );
227                anyhow::Error::msg(e.to_string())
228            })?;
229
230        let token = self.get_token().await?;
231        let path = args["path"].as_str();
232
233        let result =
234            graph_client::onedrive_list(&self.http_client, &token, self.user_id(), path).await?;
235
236        Ok(ToolResult {
237            success: true,
238            output: serde_json::to_string_pretty(&result)?,
239            error: None,
240        })
241    }
242
243    async fn handle_onedrive_download(
244        &self,
245        args: &serde_json::Value,
246    ) -> anyhow::Result<ToolResult> {
247        self.security
248            .enforce_tool_operation(ToolOperation::Read, "microsoft365.onedrive_download")
249            .map_err(|e| {
250                ::zeroclaw_log::record!(
251                    ERROR,
252                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
253                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
254                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
255                    "microsoft365: tool operation denied by policy"
256                );
257                anyhow::Error::msg(e.to_string())
258            })?;
259
260        let token = self.get_token().await?;
261        let item_id = args["item_id"].as_str().ok_or_else(|| {
262            ::zeroclaw_log::record!(
263                WARN,
264                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
265                    .with_outcome(::zeroclaw_log::EventOutcome::Failure),
266                "mod: item_id is required"
267            );
268            anyhow::Error::msg("item_id is required")
269        })?;
270        let max_size = args["max_size"]
271            .as_u64()
272            .and_then(|v| usize::try_from(v).ok())
273            .unwrap_or(MAX_ONEDRIVE_DOWNLOAD_SIZE)
274            .min(MAX_ONEDRIVE_DOWNLOAD_SIZE);
275
276        let bytes = graph_client::onedrive_download(
277            &self.http_client,
278            &token,
279            self.user_id(),
280            item_id,
281            max_size,
282        )
283        .await?;
284
285        // Return base64-encoded for binary safety.
286        use base64::Engine;
287        let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
288
289        Ok(ToolResult {
290            success: true,
291            output: format!(
292                "Downloaded {} bytes (base64 encoded):\n{encoded}",
293                bytes.len()
294            ),
295            error: None,
296        })
297    }
298
299    async fn handle_sharepoint_search(
300        &self,
301        args: &serde_json::Value,
302    ) -> anyhow::Result<ToolResult> {
303        self.security
304            .enforce_tool_operation(ToolOperation::Read, "microsoft365.sharepoint_search")
305            .map_err(|e| {
306                ::zeroclaw_log::record!(
307                    ERROR,
308                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
309                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
310                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
311                    "microsoft365: tool operation denied by policy"
312                );
313                anyhow::Error::msg(e.to_string())
314            })?;
315
316        let token = self.get_token().await?;
317        let query = args["query"].as_str().ok_or_else(|| {
318            ::zeroclaw_log::record!(
319                WARN,
320                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
321                    .with_outcome(::zeroclaw_log::EventOutcome::Failure),
322                "mod: query is required"
323            );
324            anyhow::Error::msg("query is required")
325        })?;
326        let top = u32::try_from(args["top"].as_u64().unwrap_or(u64::from(DEFAULT_TOP)))
327            .unwrap_or(DEFAULT_TOP);
328
329        let result = graph_client::sharepoint_search(&self.http_client, &token, query, top).await?;
330
331        Ok(ToolResult {
332            success: true,
333            output: serde_json::to_string_pretty(&result)?,
334            error: None,
335        })
336    }
337
338    // ── Write actions ───────────────────────────────────────────────
339
340    async fn handle_mail_send(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
341        self.security
342            .enforce_tool_operation(ToolOperation::Act, "microsoft365.mail_send")
343            .map_err(|e| {
344                ::zeroclaw_log::record!(
345                    ERROR,
346                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
347                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
348                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
349                    "microsoft365: tool operation denied by policy"
350                );
351                anyhow::Error::msg(e.to_string())
352            })?;
353
354        let token = self.get_token().await?;
355        let to: Vec<String> = args["to"]
356            .as_array()
357            .ok_or_else(|| {
358                ::zeroclaw_log::record!(
359                    WARN,
360                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
361                        .with_outcome(::zeroclaw_log::EventOutcome::Failure),
362                    "mod: to must be an array of email addresses"
363                );
364                anyhow::Error::msg("to must be an array of email addresses")
365            })?
366            .iter()
367            .filter_map(|v| v.as_str().map(String::from))
368            .collect();
369
370        if to.is_empty() {
371            anyhow::bail!("to must contain at least one email address");
372        }
373
374        let subject = args["subject"].as_str().ok_or_else(|| {
375            ::zeroclaw_log::record!(
376                WARN,
377                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
378                    .with_outcome(::zeroclaw_log::EventOutcome::Failure),
379                "mod: subject is required"
380            );
381            anyhow::Error::msg("subject is required")
382        })?;
383        let body = args["body"].as_str().ok_or_else(|| {
384            ::zeroclaw_log::record!(
385                WARN,
386                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
387                    .with_outcome(::zeroclaw_log::EventOutcome::Failure),
388                "mod: body is required"
389            );
390            anyhow::Error::msg("body is required")
391        })?;
392
393        graph_client::mail_send(
394            &self.http_client,
395            &token,
396            self.user_id(),
397            &to,
398            subject,
399            body,
400        )
401        .await?;
402
403        Ok(ToolResult {
404            success: true,
405            output: format!("Email sent to: {}", to.join(", ")),
406            error: None,
407        })
408    }
409
410    async fn handle_teams_message_send(
411        &self,
412        args: &serde_json::Value,
413    ) -> anyhow::Result<ToolResult> {
414        self.security
415            .enforce_tool_operation(ToolOperation::Act, "microsoft365.teams_message_send")
416            .map_err(|e| {
417                ::zeroclaw_log::record!(
418                    ERROR,
419                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
420                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
421                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
422                    "microsoft365: tool operation denied by policy"
423                );
424                anyhow::Error::msg(e.to_string())
425            })?;
426
427        let token = self.get_token().await?;
428        let team_id = args["team_id"].as_str().ok_or_else(|| {
429            ::zeroclaw_log::record!(
430                WARN,
431                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
432                    .with_outcome(::zeroclaw_log::EventOutcome::Failure),
433                "mod: team_id is required"
434            );
435            anyhow::Error::msg("team_id is required")
436        })?;
437        let channel_id = args["channel_id"].as_str().ok_or_else(|| {
438            ::zeroclaw_log::record!(
439                WARN,
440                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
441                    .with_outcome(::zeroclaw_log::EventOutcome::Failure),
442                "mod: channel_id is required"
443            );
444            anyhow::Error::msg("channel_id is required")
445        })?;
446        let body = args["body"].as_str().ok_or_else(|| {
447            ::zeroclaw_log::record!(
448                WARN,
449                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
450                    .with_outcome(::zeroclaw_log::EventOutcome::Failure),
451                "mod: body is required"
452            );
453            anyhow::Error::msg("body is required")
454        })?;
455
456        graph_client::teams_message_send(&self.http_client, &token, team_id, channel_id, body)
457            .await?;
458
459        Ok(ToolResult {
460            success: true,
461            output: "Teams message sent".to_string(),
462            error: None,
463        })
464    }
465
466    async fn handle_calendar_event_create(
467        &self,
468        args: &serde_json::Value,
469    ) -> anyhow::Result<ToolResult> {
470        self.security
471            .enforce_tool_operation(ToolOperation::Act, "microsoft365.calendar_event_create")
472            .map_err(|e| {
473                ::zeroclaw_log::record!(
474                    ERROR,
475                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
476                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
477                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
478                    "microsoft365: tool operation denied by policy"
479                );
480                anyhow::Error::msg(e.to_string())
481            })?;
482
483        let token = self.get_token().await?;
484        let subject = args["subject"].as_str().ok_or_else(|| {
485            ::zeroclaw_log::record!(
486                WARN,
487                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
488                    .with_outcome(::zeroclaw_log::EventOutcome::Failure),
489                "mod: subject is required"
490            );
491            anyhow::Error::msg("subject is required")
492        })?;
493        let start = args["start"].as_str().ok_or_else(|| {
494            ::zeroclaw_log::record!(
495                WARN,
496                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
497                    .with_outcome(::zeroclaw_log::EventOutcome::Failure),
498                "mod: start datetime is required"
499            );
500            anyhow::Error::msg("start datetime is required")
501        })?;
502        let end = args["end"].as_str().ok_or_else(|| {
503            ::zeroclaw_log::record!(
504                WARN,
505                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
506                    .with_outcome(::zeroclaw_log::EventOutcome::Failure),
507                "mod: end datetime is required"
508            );
509            anyhow::Error::msg("end datetime is required")
510        })?;
511        let attendees: Vec<String> = args["attendees"]
512            .as_array()
513            .map(|arr| {
514                arr.iter()
515                    .filter_map(|v| v.as_str().map(String::from))
516                    .collect()
517            })
518            .unwrap_or_default();
519        let body_text = args["body"].as_str();
520
521        let event_id = graph_client::calendar_event_create(
522            &self.http_client,
523            &token,
524            self.user_id(),
525            subject,
526            start,
527            end,
528            &attendees,
529            body_text,
530        )
531        .await?;
532
533        Ok(ToolResult {
534            success: true,
535            output: format!("Calendar event created (id: {event_id})"),
536            error: None,
537        })
538    }
539
540    async fn handle_calendar_event_delete(
541        &self,
542        args: &serde_json::Value,
543    ) -> anyhow::Result<ToolResult> {
544        self.security
545            .enforce_tool_operation(ToolOperation::Act, "microsoft365.calendar_event_delete")
546            .map_err(|e| {
547                ::zeroclaw_log::record!(
548                    ERROR,
549                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
550                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
551                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
552                    "microsoft365: tool operation denied by policy"
553                );
554                anyhow::Error::msg(e.to_string())
555            })?;
556
557        let token = self.get_token().await?;
558        let event_id = args["event_id"].as_str().ok_or_else(|| {
559            ::zeroclaw_log::record!(
560                WARN,
561                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
562                    .with_outcome(::zeroclaw_log::EventOutcome::Failure),
563                "mod: event_id is required"
564            );
565            anyhow::Error::msg("event_id is required")
566        })?;
567
568        graph_client::calendar_event_delete(&self.http_client, &token, self.user_id(), event_id)
569            .await?;
570
571        Ok(ToolResult {
572            success: true,
573            output: format!("Calendar event {event_id} deleted"),
574            error: None,
575        })
576    }
577}
578
579#[async_trait]
580impl Tool for Microsoft365Tool {
581    fn name(&self) -> &str {
582        "microsoft365"
583    }
584
585    fn description(&self) -> &str {
586        "Microsoft 365 integration: manage Outlook mail, Teams messages, Calendar events, \
587         OneDrive files, and SharePoint search via Microsoft Graph API"
588    }
589
590    fn parameters_schema(&self) -> serde_json::Value {
591        json!({
592            "type": "object",
593            "required": ["action"],
594            "properties": {
595                "action": {
596                    "type": "string",
597                    "enum": [
598                        "mail_list",
599                        "mail_send",
600                        "teams_message_list",
601                        "teams_message_send",
602                        "calendar_events_list",
603                        "calendar_event_create",
604                        "calendar_event_delete",
605                        "onedrive_list",
606                        "onedrive_download",
607                        "sharepoint_search"
608                    ],
609                    "description": "The Microsoft 365 action to perform"
610                },
611                "folder": {
612                    "type": "string",
613                    "description": "Mail folder ID (for mail_list, e.g. 'inbox', 'sentitems')"
614                },
615                "to": {
616                    "type": "array",
617                    "items": { "type": "string" },
618                    "description": "Recipient email addresses (for mail_send)"
619                },
620                "subject": {
621                    "type": "string",
622                    "description": "Email subject or calendar event subject"
623                },
624                "body": {
625                    "type": "string",
626                    "description": "Message body text"
627                },
628                "team_id": {
629                    "type": "string",
630                    "description": "Teams team ID (for teams_message_list/send)"
631                },
632                "channel_id": {
633                    "type": "string",
634                    "description": "Teams channel ID (for teams_message_list/send)"
635                },
636                "start": {
637                    "type": "string",
638                    "description": "Start datetime in ISO 8601 format (for calendar actions)"
639                },
640                "end": {
641                    "type": "string",
642                    "description": "End datetime in ISO 8601 format (for calendar actions)"
643                },
644                "attendees": {
645                    "type": "array",
646                    "items": { "type": "string" },
647                    "description": "Attendee email addresses (for calendar_event_create)"
648                },
649                "event_id": {
650                    "type": "string",
651                    "description": "Calendar event ID (for calendar_event_delete)"
652                },
653                "path": {
654                    "type": "string",
655                    "description": "OneDrive folder path (for onedrive_list)"
656                },
657                "item_id": {
658                    "type": "string",
659                    "description": "OneDrive item ID (for onedrive_download)"
660                },
661                "max_size": {
662                    "type": "integer",
663                    "description": "Maximum download size in bytes (for onedrive_download, default 10MB)"
664                },
665                "query": {
666                    "type": "string",
667                    "description": "Search query (for sharepoint_search)"
668                },
669                "top": {
670                    "type": "integer",
671                    "description": "Maximum number of items to return (default 25)"
672                }
673            }
674        })
675    }
676
677    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
678        let action = match args["action"].as_str() {
679            Some(a) => a.to_string(),
680            None => {
681                return Ok(ToolResult {
682                    success: false,
683                    output: String::new(),
684                    error: Some("'action' parameter is required".to_string()),
685                });
686            }
687        };
688
689        match self.dispatch(&action, &args).await {
690            Ok(result) => Ok(result),
691            Err(e) => Ok(ToolResult {
692                success: false,
693                output: String::new(),
694                error: Some(format!("microsoft365.{action} failed: {e}")),
695            }),
696        }
697    }
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703
704    #[test]
705    fn tool_name_is_microsoft365() {
706        // Verify the schema is valid JSON with the expected structure.
707        let schema_str = r#"{"type":"object","required":["action"]}"#;
708        let _: serde_json::Value = serde_json::from_str(schema_str).unwrap();
709    }
710
711    #[test]
712    fn parameters_schema_has_action_enum() {
713        let schema = json!({
714            "type": "object",
715            "required": ["action"],
716            "properties": {
717                "action": {
718                    "type": "string",
719                    "enum": [
720                        "mail_list",
721                        "mail_send",
722                        "teams_message_list",
723                        "teams_message_send",
724                        "calendar_events_list",
725                        "calendar_event_create",
726                        "calendar_event_delete",
727                        "onedrive_list",
728                        "onedrive_download",
729                        "sharepoint_search"
730                    ]
731                }
732            }
733        });
734
735        let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
736        assert_eq!(actions.len(), 10);
737        assert!(actions.contains(&json!("mail_list")));
738        assert!(actions.contains(&json!("sharepoint_search")));
739    }
740
741    #[test]
742    fn action_dispatch_table_is_exhaustive() {
743        let valid_actions = [
744            "mail_list",
745            "mail_send",
746            "teams_message_list",
747            "teams_message_send",
748            "calendar_events_list",
749            "calendar_event_create",
750            "calendar_event_delete",
751            "onedrive_list",
752            "onedrive_download",
753            "sharepoint_search",
754        ];
755        assert_eq!(valid_actions.len(), 10);
756        assert!(!valid_actions.contains(&"invalid_action"));
757    }
758}