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
8pub 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#[derive(Clone)]
41pub struct GVidsMcpServer {
42 tool_router: ToolRouter<Self>,
43 client: Arc<VidsClient>,
44}
45
46#[tool_router]
47impl GVidsMcpServer {
48 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 #[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(¶ms.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 #[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(¶ms.presentation_id, ¶ms.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 #[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(¶ms.presentation_id, ¶ms.object_id, ¶ms.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 #[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(¶ms.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 #[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 ¶ms.presentation_id,
264 ¶ms.find,
265 ¶ms.replace_with,
266 params.match_case,
267 )
268 .await
269 .map_err(vids_err)?;
270
271 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 #[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(¶ms.presentation_id, params.insertion_index)
300 .await
301 .map_err(vids_err)?;
302
303 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 #[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(¶ms.presentation_id, ¶ms.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 #[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 ¶ms.presentation_id,
357 ¶ms.page_id,
358 ¶ms.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 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 #[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(¶ms.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
408impl 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
456fn vids_err(e: crate::client::ClientError) -> McpError {
462 McpError::new(ErrorCode(500), e.to_string(), None)
463}
464
465fn 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 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}