linear_tools/
lib.rs

1pub mod http;
2pub mod models;
3
4/// Test support utilities (for use in tests)
5#[doc(hidden)]
6pub mod test_support;
7
8use cynic::{MutationBuilder, QueryBuilder};
9use http::LinearClient;
10use linear_queries::scalars::DateTimeOrDuration;
11use linear_queries::*;
12use regex::Regex;
13use std::sync::Arc;
14use universal_tool_core::prelude::*;
15
16/// Parse identifier "ENG-245" from plain text or URL; normalize to uppercase.
17/// Returns (team_key, number) if a valid identifier is found.
18fn parse_identifier(input: &str) -> Option<(String, i32)> {
19    let upper = input.to_uppercase();
20    let re = Regex::new(r"([A-Z]{2,10})-(\d{1,10})").unwrap();
21    if let Some(caps) = re.captures(&upper) {
22        let key = caps.get(1)?.as_str().to_string();
23        let num_str = caps.get(2)?.as_str();
24        let number: i32 = num_str.parse().ok()?;
25        return Some((key, number));
26    }
27    None
28}
29
30#[derive(Clone)]
31pub struct LinearTools {
32    api_key: Option<String>,
33}
34
35impl LinearTools {
36    pub fn new() -> Self {
37        Self {
38            api_key: std::env::var("LINEAR_API_KEY").ok(),
39        }
40    }
41
42    fn resolve_issue_id(&self, input: &str) -> IssueIdentifier {
43        // Try to parse as identifier (handles lowercase and URLs)
44        if let Some((key, number)) = parse_identifier(input) {
45            return IssueIdentifier::Identifier(format!("{}-{}", key, number));
46        }
47        // Fallback: treat as ID/UUID
48        IssueIdentifier::Id(input.to_string())
49    }
50
51    /// Resolve an issue identifier (UUID, ENG-245, or URL) to a UUID.
52    /// For identifiers, queries Linear to find the matching issue.
53    async fn resolve_to_issue_id(
54        &self,
55        client: &LinearClient,
56        input: &str,
57    ) -> Result<String, ToolError> {
58        match self.resolve_issue_id(input) {
59            IssueIdentifier::Id(id) => Ok(id),
60            IssueIdentifier::Identifier(ident) => {
61                let (team_key, number) = parse_identifier(&ident).ok_or_else(|| {
62                    ToolError::new(ErrorCode::NotFound, format!("Issue {} not found", ident))
63                })?;
64                let filter = IssueFilter {
65                    team: Some(TeamFilter {
66                        key: Some(StringComparator {
67                            eq: Some(team_key),
68                            ..Default::default()
69                        }),
70                        ..Default::default()
71                    }),
72                    number: Some(NumberComparator {
73                        eq: Some(number as f64),
74                        ..Default::default()
75                    }),
76                    ..Default::default()
77                };
78                let op = IssuesQuery::build(IssuesArguments {
79                    first: Some(1),
80                    after: None,
81                    filter: Some(filter),
82                });
83                let resp = client.run(op).await.map_err(to_tool_error)?;
84                let data = http::extract_data(resp).map_err(to_tool_error)?;
85                let issue = data.issues.nodes.into_iter().next().ok_or_else(|| {
86                    ToolError::new(ErrorCode::NotFound, format!("Issue {} not found", ident))
87                })?;
88                Ok(issue.id.inner().to_string())
89            }
90        }
91    }
92}
93
94impl Default for LinearTools {
95    fn default() -> Self {
96        Self::new()
97    }
98}
99
100enum IssueIdentifier {
101    Id(String),
102    Identifier(String),
103}
104
105fn to_tool_error(e: anyhow::Error) -> ToolError {
106    let msg = e.to_string();
107    if msg.contains("401") || msg.contains("403") || msg.contains("LINEAR_API_KEY") {
108        ToolError::new(
109            ErrorCode::PermissionDenied,
110            format!("{}\n\nHint: Ensure LINEAR_API_KEY is set and valid.", msg),
111        )
112    } else if msg.contains("429") {
113        ToolError::new(
114            ErrorCode::ExternalServiceError,
115            format!("Rate limited: {}", msg),
116        )
117    } else if msg.contains("404") {
118        ToolError::new(ErrorCode::NotFound, msg)
119    } else {
120        ToolError::new(ErrorCode::ExternalServiceError, msg)
121    }
122}
123
124#[universal_tool_router(
125    cli(name = "linear-tools", description = "Linear issue management tools"),
126    mcp(name = "linear-tools", version = "0.1.0")
127)]
128impl LinearTools {
129    /// Search Linear issues with full-text search or filters
130    #[universal_tool(
131        description = "Search Linear issues using full-text search and/or filters",
132        cli(name = "search", alias = "s"),
133        mcp(read_only = true, output = "text")
134    )]
135    #[allow(clippy::too_many_arguments)]
136    pub async fn search_issues(
137        &self,
138        #[universal_tool_param(
139            description = "Full-text search term (searches title, description, and optionally comments)"
140        )]
141        query: Option<String>,
142        #[universal_tool_param(
143            description = "Include comments in full-text search (default: true, only applies when query is provided)"
144        )]
145        include_comments: Option<bool>,
146        #[universal_tool_param(
147            description = "Filter by priority (1=Urgent, 2=High, 3=Normal, 4=Low)"
148        )]
149        priority: Option<i32>,
150        #[universal_tool_param(description = "Workflow state ID (UUID)")] state_id: Option<String>,
151        #[universal_tool_param(description = "Assignee user ID (UUID)")] assignee_id: Option<
152            String,
153        >,
154        #[universal_tool_param(description = "Team ID (UUID)")] team_id: Option<String>,
155        #[universal_tool_param(description = "Project ID (UUID)")] project_id: Option<String>,
156        #[universal_tool_param(description = "Only issues created after this ISO 8601 date")]
157        created_after: Option<String>,
158        #[universal_tool_param(description = "Only issues created before this ISO 8601 date")]
159        created_before: Option<String>,
160        #[universal_tool_param(description = "Only issues updated after this ISO 8601 date")]
161        updated_after: Option<String>,
162        #[universal_tool_param(description = "Only issues updated before this ISO 8601 date")]
163        updated_before: Option<String>,
164        #[universal_tool_param(description = "Page size (default 50, max 100)")] first: Option<i32>,
165        #[universal_tool_param(description = "Pagination cursor")] after: Option<String>,
166    ) -> Result<models::SearchResult, ToolError> {
167        let client = LinearClient::new(self.api_key.clone()).map_err(to_tool_error)?;
168
169        // Build filters (no title filter - full-text search handles query)
170        let mut filter = IssueFilter::default();
171        let mut has_filter = false;
172
173        if let Some(p) = priority {
174            filter.priority = Some(NullableNumberComparator {
175                eq: Some(p as f64),
176                ..Default::default()
177            });
178            has_filter = true;
179        }
180        if let Some(id) = state_id {
181            filter.state = Some(WorkflowStateFilter {
182                id: Some(IdComparator {
183                    eq: Some(cynic::Id::new(id)),
184                }),
185            });
186            has_filter = true;
187        }
188        if let Some(id) = assignee_id {
189            filter.assignee = Some(NullableUserFilter {
190                id: Some(IdComparator {
191                    eq: Some(cynic::Id::new(id)),
192                }),
193            });
194            has_filter = true;
195        }
196        if let Some(id) = team_id {
197            filter.team = Some(TeamFilter {
198                id: Some(IdComparator {
199                    eq: Some(cynic::Id::new(id)),
200                }),
201                ..Default::default()
202            });
203            has_filter = true;
204        }
205        if let Some(id) = project_id {
206            filter.project = Some(NullableProjectFilter {
207                id: Some(IdComparator {
208                    eq: Some(cynic::Id::new(id)),
209                }),
210            });
211            has_filter = true;
212        }
213        if created_after.is_some() || created_before.is_some() {
214            filter.created_at = Some(DateComparator {
215                gte: created_after.map(DateTimeOrDuration),
216                lte: created_before.map(DateTimeOrDuration),
217                ..Default::default()
218            });
219            has_filter = true;
220        }
221        if updated_after.is_some() || updated_before.is_some() {
222            filter.updated_at = Some(DateComparator {
223                gte: updated_after.map(DateTimeOrDuration),
224                lte: updated_before.map(DateTimeOrDuration),
225                ..Default::default()
226            });
227            has_filter = true;
228        }
229
230        let filter_opt = if has_filter { Some(filter) } else { None };
231        let page_size = Some(first.unwrap_or(50).clamp(1, 100));
232        let q_trimmed = query.as_ref().map(|s| s.trim()).unwrap_or("");
233
234        if !q_trimmed.is_empty() {
235            // Full-text search path: searchIssues
236            let op = SearchIssuesQuery::build(SearchIssuesArguments {
237                term: q_trimmed.to_string(),
238                include_comments: Some(include_comments.unwrap_or(true)),
239                first: page_size,
240                after,
241                filter: filter_opt,
242            });
243            let resp = client.run(op).await.map_err(to_tool_error)?;
244            let data = http::extract_data(resp).map_err(to_tool_error)?;
245
246            let issues = data
247                .search_issues
248                .nodes
249                .into_iter()
250                .map(|i| models::IssueSummary {
251                    id: i.id.inner().to_string(),
252                    identifier: i.identifier,
253                    title: i.title,
254                    state: Some(i.state.name),
255                    assignee: i.assignee.map(|u| {
256                        if u.display_name.is_empty() {
257                            u.name
258                        } else {
259                            u.display_name
260                        }
261                    }),
262                    priority: Some(i.priority as i32),
263                    url: Some(i.url),
264                    team_key: Some(i.team.key),
265                    updated_at: i.updated_at.0,
266                })
267                .collect();
268
269            Ok(models::SearchResult {
270                issues,
271                has_next_page: data.search_issues.page_info.has_next_page,
272                end_cursor: data.search_issues.page_info.end_cursor,
273            })
274        } else {
275            // Filters-only path: issues query
276            let op = IssuesQuery::build(IssuesArguments {
277                first: page_size,
278                after,
279                filter: filter_opt,
280            });
281
282            let resp = client.run(op).await.map_err(to_tool_error)?;
283            let data = http::extract_data(resp).map_err(to_tool_error)?;
284
285            let issues = data
286                .issues
287                .nodes
288                .into_iter()
289                .map(|i| models::IssueSummary {
290                    id: i.id.inner().to_string(),
291                    identifier: i.identifier,
292                    title: i.title,
293                    state: i.state.map(|s| s.name),
294                    assignee: i.assignee.map(|u| {
295                        if u.display_name.is_empty() {
296                            u.name
297                        } else {
298                            u.display_name
299                        }
300                    }),
301                    priority: Some(i.priority as i32),
302                    url: Some(i.url),
303                    team_key: Some(i.team.key),
304                    updated_at: i.updated_at.0,
305                })
306                .collect();
307
308            Ok(models::SearchResult {
309                issues,
310                has_next_page: data.issues.page_info.has_next_page,
311                end_cursor: data.issues.page_info.end_cursor,
312            })
313        }
314    }
315
316    /// Read a single Linear issue
317    #[universal_tool(
318        description = "Read a Linear issue by ID, identifier (e.g., ENG-245), or URL",
319        cli(name = "read", alias = "r"),
320        mcp(read_only = true, output = "text")
321    )]
322    pub async fn read_issue(
323        &self,
324        #[universal_tool_param(description = "Issue ID, identifier (e.g., ENG-245), or URL")]
325        issue: String,
326    ) -> Result<models::IssueDetails, ToolError> {
327        let client = LinearClient::new(self.api_key.clone()).map_err(to_tool_error)?;
328        let resolved = self.resolve_issue_id(&issue);
329
330        let issue_data = match resolved {
331            IssueIdentifier::Id(id) => {
332                let op = IssueByIdQuery::build(IssueByIdArguments { id });
333                let resp = client.run(op).await.map_err(to_tool_error)?;
334                let data = http::extract_data(resp).map_err(to_tool_error)?;
335                data.issue
336                    .ok_or_else(|| ToolError::new(ErrorCode::NotFound, "Issue not found"))?
337            }
338            IssueIdentifier::Identifier(ident) => {
339                // Use server-side filtering by team.key + number
340                let (team_key, number) = parse_identifier(&ident).ok_or_else(|| {
341                    ToolError::new(ErrorCode::NotFound, format!("Issue {} not found", ident))
342                })?;
343                let filter = IssueFilter {
344                    team: Some(TeamFilter {
345                        key: Some(StringComparator {
346                            eq: Some(team_key),
347                            ..Default::default()
348                        }),
349                        ..Default::default()
350                    }),
351                    number: Some(NumberComparator {
352                        eq: Some(number as f64),
353                        ..Default::default()
354                    }),
355                    ..Default::default()
356                };
357                let op = IssuesQuery::build(IssuesArguments {
358                    first: Some(1),
359                    after: None,
360                    filter: Some(filter),
361                });
362                let resp = client.run(op).await.map_err(to_tool_error)?;
363                let data = http::extract_data(resp).map_err(to_tool_error)?;
364                data.issues.nodes.into_iter().next().ok_or_else(|| {
365                    ToolError::new(ErrorCode::NotFound, format!("Issue {} not found", ident))
366                })?
367            }
368        };
369
370        let summary = models::IssueSummary {
371            id: issue_data.id.inner().to_string(),
372            identifier: issue_data.identifier.clone(),
373            title: issue_data.title.clone(),
374            state: issue_data.state.map(|s| s.name),
375            assignee: issue_data.assignee.map(|u| {
376                if u.display_name.is_empty() {
377                    u.name
378                } else {
379                    u.display_name
380                }
381            }),
382            priority: Some(issue_data.priority as i32),
383            url: Some(issue_data.url.clone()),
384            team_key: Some(issue_data.team.key),
385            updated_at: issue_data.updated_at.0.clone(),
386        };
387
388        Ok(models::IssueDetails {
389            issue: summary,
390            description: issue_data.description,
391            project: issue_data.project.map(|p| p.name),
392            created_at: issue_data.created_at.0,
393        })
394    }
395
396    /// Create a new Linear issue
397    #[universal_tool(
398        description = "Create a new Linear issue in a team",
399        cli(name = "create", alias = "c"),
400        mcp(read_only = false, output = "text")
401    )]
402    #[allow(clippy::too_many_arguments)]
403    pub async fn create_issue(
404        &self,
405        #[universal_tool_param(description = "Team ID (UUID) to create the issue in")]
406        team_id: String,
407        #[universal_tool_param(description = "Issue title")] title: String,
408        #[universal_tool_param(description = "Issue description (markdown supported)")]
409        description: Option<String>,
410        #[universal_tool_param(
411            description = "Priority (0=None, 1=Urgent, 2=High, 3=Normal, 4=Low)"
412        )]
413        priority: Option<i32>,
414        #[universal_tool_param(description = "Assignee user ID (UUID)")] assignee_id: Option<
415            String,
416        >,
417        #[universal_tool_param(description = "Project ID (UUID)")] project_id: Option<String>,
418        #[universal_tool_param(description = "Workflow state ID (UUID)")] state_id: Option<String>,
419        #[universal_tool_param(description = "Parent issue ID (UUID) for sub-issues")]
420        parent_id: Option<String>,
421        #[universal_tool_param(description = "Label IDs (UUID). Pass multiple times to add many.")]
422        label_ids: Vec<String>,
423    ) -> Result<models::CreateIssueResult, ToolError> {
424        let client = LinearClient::new(self.api_key.clone()).map_err(to_tool_error)?;
425
426        // Convert empty Vec to None for the API
427        let label_ids_opt = if label_ids.is_empty() {
428            None
429        } else {
430            Some(label_ids)
431        };
432
433        let input = IssueCreateInput {
434            team_id,
435            title: Some(title),
436            description,
437            priority,
438            assignee_id,
439            project_id,
440            state_id,
441            parent_id,
442            label_ids: label_ids_opt,
443        };
444
445        let op = IssueCreateMutation::build(IssueCreateArguments { input });
446        let resp = client.run(op).await.map_err(to_tool_error)?;
447        let data = http::extract_data(resp).map_err(to_tool_error)?;
448
449        let payload = data.issue_create;
450        let issue = payload.issue.map(|i| models::IssueSummary {
451            id: i.id.inner().to_string(),
452            identifier: i.identifier,
453            title: i.title,
454            state: i.state.map(|s| s.name),
455            assignee: i.assignee.map(|u| {
456                if u.display_name.is_empty() {
457                    u.name
458                } else {
459                    u.display_name
460                }
461            }),
462            priority: Some(i.priority as i32),
463            url: Some(i.url),
464            team_key: Some(i.team.key),
465            updated_at: i.updated_at.0,
466        });
467
468        Ok(models::CreateIssueResult {
469            success: payload.success,
470            issue,
471        })
472    }
473
474    /// Add a comment to a Linear issue
475    #[universal_tool(
476        description = "Add a comment to a Linear issue",
477        cli(name = "comment", alias = "cm"),
478        mcp(read_only = false, output = "text")
479    )]
480    pub async fn add_comment(
481        &self,
482        #[universal_tool_param(description = "Issue ID, identifier (e.g., ENG-245), or URL")]
483        issue: String,
484        #[universal_tool_param(description = "Comment body (markdown supported)")] body: String,
485        #[universal_tool_param(description = "Parent comment ID for replies (UUID)")]
486        parent_id: Option<String>,
487    ) -> Result<models::CommentResult, ToolError> {
488        let client = LinearClient::new(self.api_key.clone()).map_err(to_tool_error)?;
489        let issue_id = self.resolve_to_issue_id(&client, &issue).await?;
490
491        let input = CommentCreateInput {
492            issue_id,
493            body: Some(body),
494            parent_id,
495        };
496
497        let op = CommentCreateMutation::build(CommentCreateArguments { input });
498        let resp = client.run(op).await.map_err(to_tool_error)?;
499        let data = http::extract_data(resp).map_err(to_tool_error)?;
500
501        let payload = data.comment_create;
502        let (comment_id, body, created_at) = match payload.comment {
503            Some(c) => (
504                Some(c.id.inner().to_string()),
505                Some(c.body),
506                Some(c.created_at.0),
507            ),
508            None => (None, None, None),
509        };
510
511        Ok(models::CommentResult {
512            success: payload.success,
513            comment_id,
514            body,
515            created_at,
516        })
517    }
518}
519
520// MCP Server wrapper
521pub struct LinearToolsServer {
522    tools: Arc<LinearTools>,
523}
524
525impl LinearToolsServer {
526    pub fn new(tools: Arc<LinearTools>) -> Self {
527        Self { tools }
528    }
529}
530
531universal_tool_core::implement_mcp_server!(LinearToolsServer, tools);
532
533#[cfg(test)]
534mod tests {
535    use super::parse_identifier;
536
537    #[test]
538    fn parse_plain_uppercase() {
539        assert_eq!(parse_identifier("ENG-245"), Some(("ENG".into(), 245)));
540    }
541
542    #[test]
543    fn parse_lowercase_normalizes() {
544        assert_eq!(parse_identifier("eng-245"), Some(("ENG".into(), 245)));
545    }
546
547    #[test]
548    fn parse_from_url() {
549        assert_eq!(
550            parse_identifier("https://linear.app/foo/issue/eng-245/slug"),
551            Some(("ENG".into(), 245))
552        );
553    }
554
555    #[test]
556    fn parse_invalid_returns_none() {
557        assert_eq!(parse_identifier("invalid"), None);
558        assert_eq!(parse_identifier("ENG-"), None);
559        assert_eq!(parse_identifier("ENG"), None);
560        assert_eq!(parse_identifier("123-456"), None);
561    }
562}