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 = 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 #[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 #[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 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 #[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 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 #[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
483pub 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}