Skip to main content

figma_mcp/
server.rs

1use rmcp::{
2    handler::server::{router::tool::ToolRouter, tool::Parameters},
3    model::*,
4    schemars, tool, tool_handler, tool_router,
5    transport::stdio,
6    service::{RequestContext, RoleServer},
7    Error as McpError, ServerHandler, ServiceExt,
8};
9use serde::Deserialize;
10use std::future::Future;
11use base64::{Engine as _, engine::general_purpose};
12
13use crate::{
14    figma::{FigmaClient, FigmaUrlParser, ImageCache},
15    Error,
16};
17
18#[derive(Clone)]
19pub struct FigmaServer {
20    client: FigmaClient,
21    url_parser: FigmaUrlParser,
22    image_cache: ImageCache,
23    tool_router: ToolRouter<FigmaServer>,
24}
25
26#[tool_router]
27impl FigmaServer {
28    pub fn new(figma_token: String) -> std::result::Result<Self, Error> {
29        let client = FigmaClient::new(figma_token)?;
30        let url_parser = FigmaUrlParser::new();
31
32        Ok(Self {
33            client,
34            url_parser,
35            image_cache: ImageCache::new(),
36            tool_router: Self::tool_router(),
37        })
38    }
39
40    pub async fn run_stdio(self) -> std::result::Result<(), Error> {
41        tracing::info!("Starting Figma MCP server");
42
43        let service = self.serve(stdio()).await.map_err(|e| {
44            tracing::error!("Failed to start MCP service: {:?}", e);
45            Error::Mcp(e.into())
46        })?;
47
48        tracing::info!("MCP service started successfully, waiting for connections");
49        service.waiting().await.map_err(|e| {
50            tracing::error!("MCP service error: {:?}", e);
51            Error::Mcp(e.into())
52        })?;
53
54        Ok(())
55    }
56
57    #[tool(description = "Parse a Figma URL to extract IDs and determine the URL type")]
58    async fn parse_figma_url(
59        &self,
60        Parameters(ParseUrlRequest { url }): Parameters<ParseUrlRequest>,
61    ) -> Result<CallToolResult, McpError> {
62        let url_info = match self.url_parser.parse(&url) {
63            Ok(parsed) => parsed,
64            Err(e) => {
65                let error_msg = format!("Error parsing URL: {}", e);
66                return tool_error(error_msg);
67            }
68        };
69
70        let result = serde_json::to_string_pretty(&url_info)
71            .unwrap_or_else(|e| format!("Serialization error: {}", e));
72
73        tool_success(result)
74    }
75
76    #[tool(description = "Get file contents from a Figma file using file key")]
77    async fn get_file(
78        &self,
79        Parameters(GetFileRequest { file_key, depth }): Parameters<GetFileRequest>,
80    ) -> Result<CallToolResult, McpError> {
81        let depth = depth.unwrap_or(1);
82        let result = match self.client.get_file(&file_key, Some(depth)).await {
83            Ok(file) => file,
84            Err(e) => {
85                let error_msg = format!("Error fetching file: {}", e);
86                return tool_error(error_msg);
87            }
88        };
89
90        let result = serde_json::to_string_pretty(&result)
91            .unwrap_or_else(|e| format!("Serialization error: {}", e));
92
93        tool_success(result)
94    }
95
96    #[tool(description = "Get specific nodes from a file using file key")]
97    async fn get_file_nodes(
98        &self,
99        Parameters(GetFileNodesRequest {
100            file_key,
101            node_ids,
102            depth,
103        }): Parameters<GetFileNodesRequest>,
104    ) -> Result<CallToolResult, McpError> {
105        let node_ids: Vec<String> = node_ids.split(',').map(|s| s.trim().to_string()).collect();
106        let depth = depth.unwrap_or(1);
107
108        let result = match self
109            .client
110            .get_file_nodes(&file_key, &node_ids, Some(depth))
111            .await
112        {
113            Ok(nodes) => nodes,
114            Err(e) => {
115                let error_msg = format!("Error fetching file nodes: {}", e);
116                return tool_error(error_msg);
117            }
118        };
119
120        let result = serde_json::to_string_pretty(&result)
121            .unwrap_or_else(|e| format!("Serialization error: {}", e));
122
123        tool_success(result)
124    }
125
126    #[tool(description = "Export images from a Figma file using file key")]
127    async fn export_images(
128        &self,
129        Parameters(ExportImageRequest {
130            file_key,
131            node_ids,
132            format,
133            scale,
134        }): Parameters<ExportImageRequest>,
135    ) -> Result<CallToolResult, McpError> {
136        let node_ids_to_export: Vec<String> =
137            node_ids.split(',').map(|s| s.trim().to_string()).collect();
138
139        let format = format.as_deref().unwrap_or("png");
140        let scale_value = scale.unwrap_or(1.0);
141        
142        let result = match self
143            .client
144            .export_images(&file_key, &node_ids_to_export, format, scale)
145            .await
146        {
147            Ok(export_result) => export_result,
148            Err(e) => {
149                let error_msg = format!("Error exporting images: {}", e);
150                return tool_error(error_msg);
151            }
152        };
153
154        // Register exported images in cache
155        if let Some(images) = result.get("images").and_then(|v| v.as_object()) {
156            for (node_id, url) in images {
157                if let Some(url_str) = url.as_str() {
158                    let _ = self.image_cache.register_export(
159                        file_key.clone(),
160                        node_id.clone(),
161                        format.to_string(),
162                        scale_value,
163                        url_str.to_string(),
164                    );
165                }
166            }
167        }
168
169        let result = serde_json::to_string_pretty(&result)
170            .unwrap_or_else(|e| format!("Serialization error: {}", e));
171
172        tool_success(result)
173    }
174
175    #[tool(description = "Get current user information (useful for testing authentication)")]
176    async fn get_me(&self) -> Result<CallToolResult, McpError> {
177        let result = match self.client.get_me().await {
178            Ok(user) => user,
179            Err(e) => {
180                let error_msg = format!("Error fetching user info: {}", e);
181                return tool_error(error_msg);
182            }
183        };
184
185        let result = serde_json::to_string_pretty(&result)
186            .unwrap_or_else(|e| format!("Serialization error: {}", e));
187
188        tool_success(result)
189    }
190
191    #[tool(description = "Help: How to use this Figma file MCP server")]
192    async fn help(&self) -> Result<CallToolResult, McpError> {
193        let help_text = r#"
194# Figma MCP Server Help
195
196This MCP server provides tools to access and work with Figma files using file keys with depth control to manage response size.
197
198## Workflow
199
2001. First, use `parse_figma_url` to extract the file key from a Figma URL
2012. Then use the file key with other tools to access file data
2023. Use the depth parameter to control how much data is returned and avoid token limits
2034. Navigate deeper into the file structure using recursive calls with specific node IDs
204
205## Available Tools
206
207### URL Parsing
208- `parse_figma_url`: Parse any Figma URL to extract file key and node information
209
210### File Operations (require file key from parse_figma_url)
211- `get_file`: Get file structure using file key with depth control (default: 1)
212- `get_file_nodes`: Get specific nodes using file key with depth control (default: 1)
213- `export_images`: Export images from file using file key
214- `get_me`: Test authentication and get user info
215
216## Resources
217
218After exporting images using the `export_images` tool, they are available as MCP resources.
219You can:
220- List all exported images using the resources API
221- Access image data as base64-encoded blobs
222- Resources are identified by URIs like: `figma://file/{file_key}/node/{node_id}.{format}`
223
224## Depth Parameter
225
226Both `get_file` and `get_file_nodes` support a depth parameter to limit response size:
227
228- **depth=1** (default): For files: pages only. For nodes: direct children only
229- **depth=2**: For files: pages + top-level objects. For nodes: children + grandchildren
230- **depth=3+**: Deeper traversal (use carefully to avoid large responses)
231
232## Recursive Navigation Strategy
233
234To navigate large files without exceeding token limits:
235
2361. Start with `get_file` at depth=1 to see page structure
2372. Use `get_file_nodes` with specific page IDs at depth=1 to explore page contents
2383. Use `get_file_nodes` with specific component/frame IDs for deeper inspection
2394. Adjust depth as needed based on response size
240
241## Supported URL Formats
242- File: https://www.figma.com/file/FILE_ID/filename
243- File with node: https://www.figma.com/file/FILE_ID/filename?node-id=1%3A2
244- Design URL: https://www.figma.com/design/FILE_ID/filename
245
246## Authentication
247Set your Figma personal access token as an environment variable:
248export FIGMA_TOKEN="your_figma_token_here"
249
250Get your token from: https://www.figma.com/developers/api#access-tokens
251"#;
252
253        Ok(CallToolResult::success(vec![Content::text(
254            help_text.to_string(),
255        )]))
256    }
257}
258
259#[tool_handler]
260impl ServerHandler for FigmaServer {
261    fn get_info(&self) -> ServerInfo {
262        ServerInfo {
263            protocol_version: ProtocolVersion::V_2024_11_05,
264            server_info: Implementation::from_build_env(),
265            capabilities: ServerCapabilities::builder()
266                .enable_tools()
267                .enable_resources()
268                .build(),
269            instructions: Some("A Figma MCP server that provides tools to access Figma files and export images. Use 'help' tool for usage instructions.".into()),
270        }
271    }
272
273    fn list_resources(
274        &self,
275        _request: Option<PaginatedRequestParam>,
276        _context: RequestContext<RoleServer>,
277    ) -> impl Future<Output = Result<ListResourcesResult, McpError>> + Send + '_ {
278        async move {
279            let entries = self.image_cache.list_all()
280                .map_err(|e| McpError::internal_error(format!("Failed to list resources: {}", e), None))?;
281
282            let resources: Vec<Resource> = entries.iter().map(|(uri, entry)| {
283                let name = format!("Node {} Export", entry.node_id);
284                let description = format!(
285                    "Exported from Figma file {} as {} ({}x scale)",
286                    entry.file_key, entry.format, entry.scale
287                );
288                let mime_type = crate::figma::ImageCache::get_mime_type(&entry.format);
289
290                Resource::new(RawResource {
291                    uri: uri.clone(),
292                    name,
293                    description: Some(description),
294                    mime_type: Some(mime_type.to_string()),
295                    size: entry.cached_data.as_ref().map(|data| data.len() as u32),
296                }, None)
297            }).collect();
298
299            Ok(ListResourcesResult { resources, next_cursor: None })
300        }
301    }
302
303    fn read_resource(
304        &self,
305        request: ReadResourceRequestParam,
306        _context: RequestContext<RoleServer>,
307    ) -> impl Future<Output = Result<ReadResourceResult, McpError>> + Send + '_ {
308        async move {
309            let uri = request.uri;
310
311            let entry = self.image_cache.get_entry(&uri)
312                .map_err(|e| McpError::internal_error(format!("Failed to get resource: {}", e), None))?
313                .ok_or_else(|| McpError::resource_not_found(format!("Resource not found: {}", uri), None))?;
314
315            // Check if we need to download the image
316            let image_data = if let Some(cached_data) = entry.cached_data {
317                cached_data
318            } else {
319                // Check if URL is expired
320                if self.image_cache.is_expired(&entry) {
321                    return Err(McpError::internal_error(
322                        "Figma URL has expired. Please re-export the image.",
323                        None
324                    ));
325                }
326
327                // Download image from Figma URL
328                let response = reqwest::get(&entry.figma_url).await
329                    .map_err(|e| McpError::internal_error(format!("Failed to download image: {}", e), None))?;
330
331                if !response.status().is_success() {
332                    return Err(McpError::internal_error(
333                        format!("Failed to download image: HTTP {}", response.status()),
334                        None
335                    ));
336                }
337
338                let data = response.bytes().await
339                    .map_err(|e| McpError::internal_error(format!("Failed to read image data: {}", e), None))?
340                    .to_vec();
341
342                // Cache the downloaded data
343                let _ = self.image_cache.update_cached_data(&uri, data.clone());
344
345                data
346            };
347
348            // Convert to base64
349            let base64_data = general_purpose::STANDARD.encode(&image_data);
350            let mime_type = crate::figma::ImageCache::get_mime_type(&entry.format);
351
352            Ok(ReadResourceResult {
353                contents: vec![ResourceContents::BlobResourceContents {
354                    uri: uri.clone(),
355                    mime_type: Some(mime_type.to_string()),
356                    blob: base64_data,
357                }],
358            })
359        }
360    }
361}
362
363// Parameter structs for MCP tools
364#[derive(Debug, Deserialize, schemars::JsonSchema)]
365struct ParseUrlRequest {
366    #[schemars(description = "The Figma URL to parse (file or design URL)")]
367    pub url: String,
368}
369
370#[derive(Debug, Deserialize, schemars::JsonSchema)]
371struct GetFileRequest {
372    #[schemars(description = "The Figma file key (extract from URL using parse_figma_url)")]
373    pub file_key: String,
374    #[schemars(
375        description = "Depth to traverse into the document tree (default: 1). Use 1 for pages only, 2 for pages + top-level objects, etc."
376    )]
377    pub depth: Option<u32>,
378}
379
380#[derive(Debug, Deserialize, schemars::JsonSchema)]
381struct ExportImageRequest {
382    #[schemars(description = "The Figma file key (extract from URL using parse_figma_url)")]
383    pub file_key: String,
384    #[schemars(description = "Comma-separated node IDs to export")]
385    pub node_ids: String,
386    #[schemars(description = "Export format: png, jpg, svg, OR pdf")]
387    pub format: Option<String>,
388    #[schemars(description = "Export scale factor (1.0, 2.0, 4.0)")]
389    pub scale: Option<f64>,
390}
391
392#[derive(Debug, Deserialize, schemars::JsonSchema)]
393struct GetFileNodesRequest {
394    #[schemars(description = "The Figma file key (extract from URL using parse_figma_url)")]
395    pub file_key: String,
396    #[schemars(description = "Comma-separated list of node IDs to fetch")]
397    pub node_ids: String,
398    #[schemars(
399        description = "Depth to traverse from each node (default: 1). Use 1 for direct children only, 2 for children + grandchildren, etc."
400    )]
401    pub depth: Option<u32>,
402}
403
404// Helper functions
405fn tool_error(message: String) -> Result<CallToolResult, McpError> {
406    Ok(CallToolResult::error(vec![Content::text(message)]))
407}
408
409fn tool_success(content: String) -> Result<CallToolResult, McpError> {
410    Ok(CallToolResult::success(vec![Content::text(content)]))
411}