Skip to main content

gvids_mcp/
lib.rs

1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3#![cfg_attr(
4    not(test),
5    deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)
6)]
7
8//! Google Vids MCP Server — edit Google Vids videos via Google Slides API.
9//!
10//! Google Vids uses the same underlying engine as Google Slides. This server
11//! exposes the Slides API `presentations.batchUpdate` as MCP tools, enabling
12//! reliable text editing (with spaces!), scene management, and batch operations.
13//!
14//! Tier: T3 (μ Mapping + σ Sequence + ∂ Boundary + ς State + π Persistence + ∃ Existence)
15
16pub mod auth;
17pub mod client;
18pub mod types;
19
20use std::sync::Arc;
21
22use rmcp::handler::server::router::tool::ToolRouter;
23use rmcp::handler::server::tool::ToolCallContext;
24use rmcp::handler::server::wrapper::Parameters;
25use rmcp::model::{
26    CallToolRequestParams, CallToolResult, ErrorCode, Implementation, ListToolsResult,
27    PaginatedRequestParams, ServerCapabilities, ServerInfo,
28};
29use rmcp::service::{RequestContext, RoleServer};
30use rmcp::{ErrorData as McpError, ServerHandler, tool, tool_router};
31use serde_json::json;
32
33use crate::client::VidsClient;
34use crate::types::{
35    AddSceneParam, BatchUpdateParam, CreateTextBoxParam, DeleteObjectParam, GetSceneParam,
36    InsertTextParam, PresentationIdParam, ReplaceTextParam, SetTextParam,
37};
38
39/// MCP server for Google Vids editing via Slides API.
40#[derive(Clone)]
41pub struct GVidsMcpServer {
42    tool_router: ToolRouter<Self>,
43    client: Arc<VidsClient>,
44}
45
46#[tool_router]
47impl GVidsMcpServer {
48    /// Create the server, authenticating with Google on startup.
49    pub async fn new() -> Result<Self, nexcore_error::NexError> {
50        let client = VidsClient::new().await?;
51        Ok(Self {
52            tool_router: Self::tool_router(),
53            client: Arc::new(client),
54        })
55    }
56
57    // -----------------------------------------------------------------------
58    // Tool: list_scenes
59    // -----------------------------------------------------------------------
60
61    #[tool(
62        description = "List all scenes (slides/pages) in a Google Vids video. Returns scene IDs, indices, element counts, and text summaries."
63    )]
64    async fn gvids_list_scenes(
65        &self,
66        Parameters(params): Parameters<PresentationIdParam>,
67    ) -> Result<CallToolResult, McpError> {
68        let presentation = self
69            .client
70            .get_presentation(&params.presentation_id)
71            .await
72            .map_err(vids_err)?;
73
74        let title = presentation.title.as_deref().unwrap_or("(untitled)");
75        let mut lines = Vec::new();
76        lines.push(format!("Video: {title}"));
77        lines.push(format!("Scenes: {}", presentation.slides.len()));
78
79        if let Some(ref size) = presentation.page_size {
80            let w = size.width.as_ref().and_then(|d| d.magnitude).unwrap_or(0.0);
81            let h = size
82                .height
83                .as_ref()
84                .and_then(|d| d.magnitude)
85                .unwrap_or(0.0);
86            lines.push(format!("Page size: {w:.0} x {h:.0} EMU"));
87        }
88
89        lines.push(String::new());
90
91        for (i, slide) in presentation.slides.iter().enumerate() {
92            let elem_count = slide.page_elements.len();
93            let text_elements: Vec<String> = slide
94                .page_elements
95                .iter()
96                .filter_map(|pe| {
97                    let text = pe.text_content()?;
98                    let trimmed = text.trim().replace('\n', " ");
99                    let preview = if trimmed.len() > 60 {
100                        format!("{}...", &trimmed[..57])
101                    } else {
102                        trimmed
103                    };
104                    Some(format!(
105                        "    {} [{}]: \"{}\"",
106                        pe.object_id,
107                        pe.shape_type().unwrap_or("?"),
108                        preview
109                    ))
110                })
111                .collect();
112
113            lines.push(format!(
114                "Scene {} | id={} | elements={}",
115                i + 1,
116                slide.object_id,
117                elem_count
118            ));
119
120            if text_elements.is_empty() {
121                lines.push("    (no text elements)".to_string());
122            } else {
123                for te in text_elements {
124                    lines.push(te);
125                }
126            }
127        }
128
129        Ok(text_result(&lines.join("\n")))
130    }
131
132    // -----------------------------------------------------------------------
133    // Tool: get_scene
134    // -----------------------------------------------------------------------
135
136    #[tool(
137        description = "Get detailed information about a specific scene including all elements, text content, positions, and sizes."
138    )]
139    async fn gvids_get_scene(
140        &self,
141        Parameters(params): Parameters<GetSceneParam>,
142    ) -> Result<CallToolResult, McpError> {
143        let page = self
144            .client
145            .get_page(&params.presentation_id, &params.page_id)
146            .await
147            .map_err(vids_err)?;
148
149        let mut lines = Vec::new();
150        lines.push(format!(
151            "Scene: {} (type: {})",
152            page.object_id,
153            page.page_type.as_deref().unwrap_or("SLIDE")
154        ));
155        lines.push(format!("Elements: {}", page.page_elements.len()));
156        lines.push(String::new());
157
158        for pe in &page.page_elements {
159            lines.push(format!("--- Element: {} ---", pe.object_id));
160
161            if let Some(shape_type) = pe.shape_type() {
162                lines.push(format!("  Type: {shape_type}"));
163            }
164            if let Some(ph_type) = pe.placeholder_type() {
165                lines.push(format!("  Placeholder: {ph_type}"));
166            }
167            if let Some(ref size) = pe.size {
168                let w = size.width.as_ref().and_then(|d| d.magnitude).unwrap_or(0.0);
169                let h = size
170                    .height
171                    .as_ref()
172                    .and_then(|d| d.magnitude)
173                    .unwrap_or(0.0);
174                lines.push(format!("  Size: {w:.0} x {h:.0} EMU"));
175            }
176            if let Some(text) = pe.text_content() {
177                let display = text.trim().replace('\n', "\\n");
178                lines.push(format!("  Text: \"{display}\""));
179            }
180            if pe.image.is_some() {
181                lines.push("  [IMAGE]".to_string());
182            }
183            if pe.video.is_some() {
184                lines.push("  [VIDEO]".to_string());
185            }
186        }
187
188        Ok(text_result(&lines.join("\n")))
189    }
190
191    // -----------------------------------------------------------------------
192    // Tool: set_text
193    // -----------------------------------------------------------------------
194
195    #[tool(
196        description = "Set text on a shape element, replacing all existing text. This is the primary text editing tool — it correctly handles spaces, unlike Chrome DevTools fill()."
197    )]
198    async fn gvids_set_text(
199        &self,
200        Parameters(params): Parameters<SetTextParam>,
201    ) -> Result<CallToolResult, McpError> {
202        let resp = self
203            .client
204            .set_text(&params.presentation_id, &params.object_id, &params.text)
205            .await
206            .map_err(vids_err)?;
207
208        Ok(text_result(&format!(
209            "Text set successfully on {}.\nReplies: {}",
210            params.object_id,
211            resp.replies.len()
212        )))
213    }
214
215    // -----------------------------------------------------------------------
216    // Tool: insert_text
217    // -----------------------------------------------------------------------
218
219    #[tool(
220        description = "Insert text at a specific position in a shape element. Use insertion_index=0 for beginning, or omit to append."
221    )]
222    async fn gvids_insert_text(
223        &self,
224        Parameters(params): Parameters<InsertTextParam>,
225    ) -> Result<CallToolResult, McpError> {
226        let idx = params.insertion_index.unwrap_or(0);
227        let requests = vec![json!({
228            "insertText": {
229                "objectId": params.object_id,
230                "insertionIndex": idx,
231                "text": params.text
232            }
233        })];
234
235        let resp = self
236            .client
237            .batch_update(&params.presentation_id, requests)
238            .await
239            .map_err(vids_err)?;
240
241        Ok(text_result(&format!(
242            "Text inserted at index {} in {}.\nReplies: {}",
243            idx,
244            params.object_id,
245            resp.replies.len()
246        )))
247    }
248
249    // -----------------------------------------------------------------------
250    // Tool: replace_text
251    // -----------------------------------------------------------------------
252
253    #[tool(
254        description = "Find and replace text across ALL scenes in the video. Useful for batch text corrections."
255    )]
256    async fn gvids_replace_text(
257        &self,
258        Parameters(params): Parameters<ReplaceTextParam>,
259    ) -> Result<CallToolResult, McpError> {
260        let resp = self
261            .client
262            .replace_all_text(
263                &params.presentation_id,
264                &params.find,
265                &params.replace_with,
266                params.match_case,
267            )
268            .await
269            .map_err(vids_err)?;
270
271        // The reply contains occurrencesChanged
272        let changed = resp
273            .replies
274            .first()
275            .and_then(|r| r.get("replaceAllText"))
276            .and_then(|r| r.get("occurrencesChanged"))
277            .and_then(|v| v.as_u64())
278            .unwrap_or(0);
279
280        Ok(text_result(&format!(
281            "Replaced '{}' → '{}': {changed} occurrence(s) changed",
282            params.find, params.replace_with
283        )))
284    }
285
286    // -----------------------------------------------------------------------
287    // Tool: add_scene
288    // -----------------------------------------------------------------------
289
290    #[tool(
291        description = "Add a new blank scene (slide) to the video. Optionally specify position and layout."
292    )]
293    async fn gvids_add_scene(
294        &self,
295        Parameters(params): Parameters<AddSceneParam>,
296    ) -> Result<CallToolResult, McpError> {
297        let resp = self
298            .client
299            .create_slide(&params.presentation_id, params.insertion_index)
300            .await
301            .map_err(vids_err)?;
302
303        // Extract the created slide's object ID from the reply
304        let new_id = resp
305            .replies
306            .first()
307            .and_then(|r| r.get("createSlide"))
308            .and_then(|r| r.get("objectId"))
309            .and_then(|v| v.as_str())
310            .unwrap_or("(unknown)");
311
312        Ok(text_result(&format!(
313            "Scene created: {new_id} (at index {})",
314            params
315                .insertion_index
316                .map_or("end".to_string(), |i| i.to_string())
317        )))
318    }
319
320    // -----------------------------------------------------------------------
321    // Tool: delete_object
322    // -----------------------------------------------------------------------
323
324    #[tool(description = "Delete a scene (page) or element (shape/image) by its object ID.")]
325    async fn gvids_delete_object(
326        &self,
327        Parameters(params): Parameters<DeleteObjectParam>,
328    ) -> Result<CallToolResult, McpError> {
329        let resp = self
330            .client
331            .delete_object(&params.presentation_id, &params.object_id)
332            .await
333            .map_err(vids_err)?;
334
335        Ok(text_result(&format!(
336            "Deleted object: {}\nReplies: {}",
337            params.object_id,
338            resp.replies.len()
339        )))
340    }
341
342    // -----------------------------------------------------------------------
343    // Tool: create_text_box
344    // -----------------------------------------------------------------------
345
346    #[tool(
347        description = "Create a new text box on a scene with specified text, position, and size. Position/size use EMU (1 inch = 914400 EMU)."
348    )]
349    async fn gvids_create_text_box(
350        &self,
351        Parameters(params): Parameters<CreateTextBoxParam>,
352    ) -> Result<CallToolResult, McpError> {
353        let resp = self
354            .client
355            .create_text_box(
356                &params.presentation_id,
357                &params.page_id,
358                &params.text,
359                params.x_emu,
360                params.y_emu,
361                params.width_emu,
362                params.height_emu,
363            )
364            .await
365            .map_err(vids_err)?;
366
367        // Extract created shape ID
368        let new_id = resp
369            .replies
370            .first()
371            .and_then(|r| r.get("createShape"))
372            .and_then(|r| r.get("objectId"))
373            .and_then(|v| v.as_str())
374            .unwrap_or("(unknown)");
375
376        Ok(text_result(&format!(
377            "Text box created: {new_id} on page {}\nText: \"{}\"",
378            params.page_id, params.text
379        )))
380    }
381
382    // -----------------------------------------------------------------------
383    // Tool: batch_update
384    // -----------------------------------------------------------------------
385
386    #[tool(
387        description = "Execute a raw batchUpdate with custom request objects. For advanced operations not covered by other tools. See Google Slides API batchUpdate docs for request format."
388    )]
389    async fn gvids_batch_update(
390        &self,
391        Parameters(params): Parameters<BatchUpdateParam>,
392    ) -> Result<CallToolResult, McpError> {
393        let req_count = params.requests.len();
394        let resp = self
395            .client
396            .batch_update(&params.presentation_id, params.requests)
397            .await
398            .map_err(vids_err)?;
399
400        let reply_summary = serde_json::to_string_pretty(&resp.replies).unwrap_or_default();
401        Ok(text_result(&format!(
402            "Batch update complete: {req_count} request(s), {} reply(ies)\n{reply_summary}",
403            resp.replies.len()
404        )))
405    }
406}
407
408// ---------------------------------------------------------------------------
409// ServerHandler impl
410// ---------------------------------------------------------------------------
411
412impl ServerHandler for GVidsMcpServer {
413    fn get_info(&self) -> ServerInfo {
414        ServerInfo {
415            instructions: Some(
416                "Google Vids MCP Server\n\nEdit Google Vids videos via Google Slides API.\nSet text (with spaces!), manage scenes, find/replace, create text boxes.\nAuthentication: gcloud ADC or service account."
417                    .into(),
418            ),
419            capabilities: ServerCapabilities::builder().enable_tools().build(),
420            server_info: Implementation {
421                name: "gvids-mcp".into(),
422                version: env!("CARGO_PKG_VERSION").into(),
423                title: Some("Google Vids MCP Server".into()),
424                icons: None,
425                website_url: None,
426            },
427            ..Default::default()
428        }
429    }
430
431    fn call_tool(
432        &self,
433        request: CallToolRequestParams,
434        context: RequestContext<RoleServer>,
435    ) -> impl std::future::Future<Output = Result<CallToolResult, McpError>> + Send + '_ {
436        async move {
437            let tcc = ToolCallContext::new(self, request, context);
438            let result = self.tool_router.call(tcc).await?;
439            Ok(result)
440        }
441    }
442
443    fn list_tools(
444        &self,
445        _request: Option<PaginatedRequestParams>,
446        _context: RequestContext<RoleServer>,
447    ) -> impl std::future::Future<Output = Result<ListToolsResult, McpError>> + Send + '_ {
448        std::future::ready(Ok(ListToolsResult {
449            tools: self.tool_router.list_all(),
450            meta: None,
451            next_cursor: None,
452        }))
453    }
454}
455
456// ---------------------------------------------------------------------------
457// Helpers
458// ---------------------------------------------------------------------------
459
460/// Convert a `ClientError` to an MCP error.
461fn vids_err(e: crate::client::ClientError) -> McpError {
462    McpError::new(ErrorCode(500), e.to_string(), None)
463}
464
465/// Shorthand for a text-only `CallToolResult`.
466fn text_result(s: &str) -> CallToolResult {
467    CallToolResult::success(vec![rmcp::model::Content::text(s)])
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    #[test]
475    fn text_result_creates_success() {
476        let result = text_result("hello");
477        // CallToolResult has is_error field; success means it's not set or false
478        assert!(!result.is_error.unwrap_or(false));
479    }
480
481    #[test]
482    fn vids_err_uses_500() {
483        let err = vids_err(crate::client::ClientError::Http("test".into()));
484        assert_eq!(err.code.0, 500);
485    }
486}