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 = resp.data.ok_or_else(|| {
85                    ToolError::new(ErrorCode::ExternalServiceError, "No data returned")
86                })?;
87                let issue = data.issues.nodes.into_iter().next().ok_or_else(|| {
88                    ToolError::new(ErrorCode::NotFound, format!("Issue {} not found", ident))
89                })?;
90                Ok(issue.id.inner().to_string())
91            }
92        }
93    }
94}
95
96impl Default for LinearTools {
97    fn default() -> Self {
98        Self::new()
99    }
100}
101
102enum IssueIdentifier {
103    Id(String),
104    Identifier(String),
105}
106
107fn to_tool_error(e: anyhow::Error) -> ToolError {
108    let msg = e.to_string();
109    if msg.contains("401") || msg.contains("403") || msg.contains("LINEAR_API_KEY") {
110        ToolError::new(
111            ErrorCode::PermissionDenied,
112            format!("{}\n\nHint: Ensure LINEAR_API_KEY is set and valid.", msg),
113        )
114    } else if msg.contains("429") {
115        ToolError::new(
116            ErrorCode::ExternalServiceError,
117            format!("Rate limited: {}", msg),
118        )
119    } else if msg.contains("404") {
120        ToolError::new(ErrorCode::NotFound, msg)
121    } else {
122        ToolError::new(ErrorCode::ExternalServiceError, msg)
123    }
124}
125
126#[universal_tool_router(
127    cli(name = "linear-tools", description = "Linear issue management tools"),
128    mcp(name = "linear-tools", version = "0.1.0")
129)]
130impl LinearTools {
131    /// Search Linear issues with filters
132    #[universal_tool(
133        description = "Search Linear issues using filters",
134        cli(name = "search", alias = "s"),
135        mcp(read_only = true, output = "text")
136    )]
137    #[allow(clippy::too_many_arguments)]
138    pub async fn search_issues(
139        &self,
140        #[universal_tool_param(description = "Text to search in title")] query: Option<String>,
141        #[universal_tool_param(
142            description = "Filter by priority (1=Urgent, 2=High, 3=Normal, 4=Low)"
143        )]
144        priority: Option<i32>,
145        #[universal_tool_param(description = "Workflow state ID (UUID)")] state_id: Option<String>,
146        #[universal_tool_param(description = "Assignee user ID (UUID)")] assignee_id: Option<
147            String,
148        >,
149        #[universal_tool_param(description = "Team ID (UUID)")] team_id: Option<String>,
150        #[universal_tool_param(description = "Project ID (UUID)")] project_id: Option<String>,
151        #[universal_tool_param(description = "Only issues created after this ISO 8601 date")]
152        created_after: Option<String>,
153        #[universal_tool_param(description = "Only issues created before this ISO 8601 date")]
154        created_before: Option<String>,
155        #[universal_tool_param(description = "Only issues updated after this ISO 8601 date")]
156        updated_after: Option<String>,
157        #[universal_tool_param(description = "Only issues updated before this ISO 8601 date")]
158        updated_before: Option<String>,
159        #[universal_tool_param(description = "Page size (default 50, max 100)")] first: Option<i32>,
160        #[universal_tool_param(description = "Pagination cursor")] after: Option<String>,
161    ) -> Result<models::SearchResult, ToolError> {
162        let client = LinearClient::new(self.api_key.clone()).map_err(to_tool_error)?;
163
164        let mut filter = IssueFilter::default();
165        if let Some(q) = query {
166            filter.title = Some(StringComparator {
167                contains_ignore_case: Some(q),
168                ..Default::default()
169            });
170        }
171        if let Some(p) = priority {
172            filter.priority = Some(NullableNumberComparator {
173                eq: Some(p as f64),
174                ..Default::default()
175            });
176        }
177        if let Some(id) = state_id {
178            filter.state = Some(WorkflowStateFilter {
179                id: Some(IdComparator {
180                    eq: Some(cynic::Id::new(id)),
181                }),
182            });
183        }
184        if let Some(id) = assignee_id {
185            filter.assignee = Some(NullableUserFilter {
186                id: Some(IdComparator {
187                    eq: Some(cynic::Id::new(id)),
188                }),
189            });
190        }
191        if let Some(id) = team_id {
192            filter.team = Some(TeamFilter {
193                id: Some(IdComparator {
194                    eq: Some(cynic::Id::new(id)),
195                }),
196                ..Default::default()
197            });
198        }
199        if let Some(id) = project_id {
200            filter.project = Some(NullableProjectFilter {
201                id: Some(IdComparator {
202                    eq: Some(cynic::Id::new(id)),
203                }),
204            });
205        }
206        if created_after.is_some() || created_before.is_some() {
207            filter.created_at = Some(DateComparator {
208                gte: created_after.map(DateTimeOrDuration),
209                lte: created_before.map(DateTimeOrDuration),
210                ..Default::default()
211            });
212        }
213        if updated_after.is_some() || updated_before.is_some() {
214            filter.updated_at = Some(DateComparator {
215                gte: updated_after.map(DateTimeOrDuration),
216                lte: updated_before.map(DateTimeOrDuration),
217                ..Default::default()
218            });
219        }
220
221        let op = IssuesQuery::build(IssuesArguments {
222            first: Some(first.unwrap_or(50).clamp(1, 100)),
223            after,
224            filter: Some(filter),
225        });
226
227        let resp = client.run(op).await.map_err(to_tool_error)?;
228        let data = resp.data.ok_or_else(|| {
229            ToolError::new(
230                ErrorCode::ExternalServiceError,
231                "No data returned from Linear",
232            )
233        })?;
234
235        let issues = data
236            .issues
237            .nodes
238            .into_iter()
239            .map(|i| models::IssueSummary {
240                id: i.id.inner().to_string(),
241                identifier: i.identifier,
242                title: i.title,
243                state: i.state.map(|s| s.name),
244                assignee: i.assignee.map(|u| {
245                    if u.display_name.is_empty() {
246                        u.name
247                    } else {
248                        u.display_name
249                    }
250                }),
251                priority: Some(i.priority as i32),
252                url: Some(i.url),
253                team_key: Some(i.team.key),
254                updated_at: i.updated_at.0,
255            })
256            .collect();
257
258        Ok(models::SearchResult {
259            issues,
260            has_next_page: data.issues.page_info.has_next_page,
261            end_cursor: data.issues.page_info.end_cursor,
262        })
263    }
264
265    /// Read a single Linear issue
266    #[universal_tool(
267        description = "Read a Linear issue by ID, identifier (e.g., ENG-245), or URL",
268        cli(name = "read", alias = "r"),
269        mcp(read_only = true, output = "text")
270    )]
271    pub async fn read_issue(
272        &self,
273        #[universal_tool_param(description = "Issue ID, identifier (e.g., ENG-245), or URL")]
274        issue: String,
275    ) -> Result<models::IssueDetails, ToolError> {
276        let client = LinearClient::new(self.api_key.clone()).map_err(to_tool_error)?;
277        let resolved = self.resolve_issue_id(&issue);
278
279        let issue_data = match resolved {
280            IssueIdentifier::Id(id) => {
281                let op = IssueByIdQuery::build(IssueByIdArguments { id });
282                let resp = client.run(op).await.map_err(to_tool_error)?;
283                let data = resp.data.ok_or_else(|| {
284                    ToolError::new(ErrorCode::ExternalServiceError, "No data returned")
285                })?;
286                data.issue
287                    .ok_or_else(|| ToolError::new(ErrorCode::NotFound, "Issue not found"))?
288            }
289            IssueIdentifier::Identifier(ident) => {
290                // Use server-side filtering by team.key + number
291                let (team_key, number) = parse_identifier(&ident).ok_or_else(|| {
292                    ToolError::new(ErrorCode::NotFound, format!("Issue {} not found", ident))
293                })?;
294                let filter = IssueFilter {
295                    team: Some(TeamFilter {
296                        key: Some(StringComparator {
297                            eq: Some(team_key),
298                            ..Default::default()
299                        }),
300                        ..Default::default()
301                    }),
302                    number: Some(NumberComparator {
303                        eq: Some(number as f64),
304                        ..Default::default()
305                    }),
306                    ..Default::default()
307                };
308                let op = IssuesQuery::build(IssuesArguments {
309                    first: Some(1),
310                    after: None,
311                    filter: Some(filter),
312                });
313                let resp = client.run(op).await.map_err(to_tool_error)?;
314                let data = resp.data.ok_or_else(|| {
315                    ToolError::new(ErrorCode::ExternalServiceError, "No data returned")
316                })?;
317                data.issues.nodes.into_iter().next().ok_or_else(|| {
318                    ToolError::new(ErrorCode::NotFound, format!("Issue {} not found", ident))
319                })?
320            }
321        };
322
323        let summary = models::IssueSummary {
324            id: issue_data.id.inner().to_string(),
325            identifier: issue_data.identifier.clone(),
326            title: issue_data.title.clone(),
327            state: issue_data.state.map(|s| s.name),
328            assignee: issue_data.assignee.map(|u| {
329                if u.display_name.is_empty() {
330                    u.name
331                } else {
332                    u.display_name
333                }
334            }),
335            priority: Some(issue_data.priority as i32),
336            url: Some(issue_data.url.clone()),
337            team_key: Some(issue_data.team.key),
338            updated_at: issue_data.updated_at.0.clone(),
339        };
340
341        Ok(models::IssueDetails {
342            issue: summary,
343            description: issue_data.description,
344            project: issue_data.project.map(|p| p.name),
345            created_at: issue_data.created_at.0,
346        })
347    }
348
349    /// Create a new Linear issue
350    #[universal_tool(
351        description = "Create a new Linear issue in a team",
352        cli(name = "create", alias = "c"),
353        mcp(read_only = false, output = "text")
354    )]
355    #[allow(clippy::too_many_arguments)]
356    pub async fn create_issue(
357        &self,
358        #[universal_tool_param(description = "Team ID (UUID) to create the issue in")]
359        team_id: String,
360        #[universal_tool_param(description = "Issue title")] title: String,
361        #[universal_tool_param(description = "Issue description (markdown supported)")]
362        description: Option<String>,
363        #[universal_tool_param(
364            description = "Priority (0=None, 1=Urgent, 2=High, 3=Normal, 4=Low)"
365        )]
366        priority: Option<i32>,
367        #[universal_tool_param(description = "Assignee user ID (UUID)")] assignee_id: Option<
368            String,
369        >,
370        #[universal_tool_param(description = "Project ID (UUID)")] project_id: Option<String>,
371        #[universal_tool_param(description = "Workflow state ID (UUID)")] state_id: Option<String>,
372        #[universal_tool_param(description = "Parent issue ID (UUID) for sub-issues")]
373        parent_id: Option<String>,
374        #[universal_tool_param(description = "Label IDs (UUID). Pass multiple times to add many.")]
375        label_ids: Vec<String>,
376    ) -> Result<models::CreateIssueResult, ToolError> {
377        let client = LinearClient::new(self.api_key.clone()).map_err(to_tool_error)?;
378
379        // Convert empty Vec to None for the API
380        let label_ids_opt = if label_ids.is_empty() {
381            None
382        } else {
383            Some(label_ids)
384        };
385
386        let input = IssueCreateInput {
387            team_id,
388            title: Some(title),
389            description,
390            priority,
391            assignee_id,
392            project_id,
393            state_id,
394            parent_id,
395            label_ids: label_ids_opt,
396        };
397
398        let op = IssueCreateMutation::build(IssueCreateArguments { input });
399        let resp = client.run(op).await.map_err(to_tool_error)?;
400        let data = resp.data.ok_or_else(|| {
401            ToolError::new(
402                ErrorCode::ExternalServiceError,
403                "No data returned from Linear",
404            )
405        })?;
406
407        let payload = data.issue_create;
408        let issue = payload.issue.map(|i| models::IssueSummary {
409            id: i.id.inner().to_string(),
410            identifier: i.identifier,
411            title: i.title,
412            state: i.state.map(|s| s.name),
413            assignee: i.assignee.map(|u| {
414                if u.display_name.is_empty() {
415                    u.name
416                } else {
417                    u.display_name
418                }
419            }),
420            priority: Some(i.priority as i32),
421            url: Some(i.url),
422            team_key: Some(i.team.key),
423            updated_at: i.updated_at.0,
424        });
425
426        Ok(models::CreateIssueResult {
427            success: payload.success,
428            issue,
429        })
430    }
431
432    /// Add a comment to a Linear issue
433    #[universal_tool(
434        description = "Add a comment to a Linear issue",
435        cli(name = "comment", alias = "cm"),
436        mcp(read_only = false, output = "text")
437    )]
438    pub async fn add_comment(
439        &self,
440        #[universal_tool_param(description = "Issue ID, identifier (e.g., ENG-245), or URL")]
441        issue: String,
442        #[universal_tool_param(description = "Comment body (markdown supported)")] body: String,
443        #[universal_tool_param(description = "Parent comment ID for replies (UUID)")]
444        parent_id: Option<String>,
445    ) -> Result<models::CommentResult, ToolError> {
446        let client = LinearClient::new(self.api_key.clone()).map_err(to_tool_error)?;
447        let issue_id = self.resolve_to_issue_id(&client, &issue).await?;
448
449        let input = CommentCreateInput {
450            issue_id,
451            body: Some(body),
452            parent_id,
453        };
454
455        let op = CommentCreateMutation::build(CommentCreateArguments { input });
456        let resp = client.run(op).await.map_err(to_tool_error)?;
457        let data = resp.data.ok_or_else(|| {
458            ToolError::new(
459                ErrorCode::ExternalServiceError,
460                "No data returned from Linear",
461            )
462        })?;
463
464        let payload = data.comment_create;
465        let (comment_id, body, created_at) = match payload.comment {
466            Some(c) => (
467                Some(c.id.inner().to_string()),
468                Some(c.body),
469                Some(c.created_at.0),
470            ),
471            None => (None, None, None),
472        };
473
474        Ok(models::CommentResult {
475            success: payload.success,
476            comment_id,
477            body,
478            created_at,
479        })
480    }
481}
482
483// MCP Server wrapper
484pub struct LinearToolsServer {
485    tools: Arc<LinearTools>,
486}
487
488impl LinearToolsServer {
489    pub fn new(tools: Arc<LinearTools>) -> Self {
490        Self { tools }
491    }
492}
493
494universal_tool_core::implement_mcp_server!(LinearToolsServer, tools);
495
496#[cfg(test)]
497mod tests {
498    use super::parse_identifier;
499
500    #[test]
501    fn parse_plain_uppercase() {
502        assert_eq!(parse_identifier("ENG-245"), Some(("ENG".into(), 245)));
503    }
504
505    #[test]
506    fn parse_lowercase_normalizes() {
507        assert_eq!(parse_identifier("eng-245"), Some(("ENG".into(), 245)));
508    }
509
510    #[test]
511    fn parse_from_url() {
512        assert_eq!(
513            parse_identifier("https://linear.app/foo/issue/eng-245/slug"),
514            Some(("ENG".into(), 245))
515        );
516    }
517
518    #[test]
519    fn parse_invalid_returns_none() {
520        assert_eq!(parse_identifier("invalid"), None);
521        assert_eq!(parse_identifier("ENG-"), None);
522        assert_eq!(parse_identifier("ENG"), None);
523        assert_eq!(parse_identifier("123-456"), None);
524    }
525}