1pub mod http;
2pub mod models;
3
4#[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
16fn 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 if let Some((key, number)) = parse_identifier(input) {
45 return IssueIdentifier::Identifier(format!("{}-{}", key, number));
46 }
47 IssueIdentifier::Id(input.to_string())
49 }
50
51 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 #[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 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 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 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 #[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 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 #[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 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 #[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
520pub 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}