use anyhow::Result;
use rmcp::model::*;
use rmcp::schemars::JsonSchema;
use rmcp::{tool, ServerHandler};
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::db::Database;
use crate::embedding::{self, Embedder};
use crate::linear::LinearClient;
use crate::search::{self, SearchMode};
/// Scan a JSON value for issue identifiers (e.g. "CUT-42") and add a `referenced_issues`
/// field with their URLs so agents can render them as clickable links.
fn enrich_with_issue_links(value: &mut serde_json::Value, db: &Database) {
let text = value.to_string();
let identifiers = extract_issue_identifiers(&text);
if identifiers.is_empty() {
return;
}
let mut refs = serde_json::Map::new();
for ident in &identifiers {
if let Ok(Some(issue)) = db.get_issue(ident) {
if !issue.url.is_empty() {
refs.insert(
ident.clone(),
serde_json::json!({
"url": issue.url,
"title": issue.title,
"state": issue.state_name,
}),
);
}
}
}
if !refs.is_empty() {
if let serde_json::Value::Object(map) = value {
map.insert(
"referenced_issues".to_string(),
serde_json::Value::Object(refs),
);
}
}
}
fn attach_comments_payload(
value: &mut serde_json::Value,
db: &Database,
issue_id: &str,
) -> Result<(), String> {
let comments = db.get_comments(issue_id).map_err(|e| e.to_string())?;
let sync_state = db
.get_comment_sync_state(issue_id)
.map_err(|e| e.to_string())?;
value["comments"] = serde_json::to_value(&comments).map_err(|e| e.to_string())?;
value["comments_status"] = serde_json::Value::String(sync_state.status);
value["comments_synced_at"] =
serde_json::to_value(sync_state.synced_at).map_err(|e| e.to_string())?;
value["comments_sync_error"] =
serde_json::to_value(sync_state.sync_error).map_err(|e| e.to_string())?;
Ok(())
}
/// Extract issue identifiers (e.g. "CUT-42", "ENG-123") from text.
/// Matches patterns like 1-4 uppercase letters followed by a dash and digits.
fn extract_issue_identifiers(text: &str) -> Vec<String> {
let mut result = Vec::new();
let mut seen = std::collections::HashSet::new();
let bytes = text.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
// Look for uppercase letter start
if bytes[i].is_ascii_uppercase() {
let start = i;
// Consume 1-6 uppercase letters
while i < len && bytes[i].is_ascii_uppercase() {
i += 1;
}
let key_len = i - start;
if (1..=6).contains(&key_len) && i < len && bytes[i] == b'-' {
i += 1; // skip dash
let digit_start = i;
while i < len && bytes[i].is_ascii_digit() {
i += 1;
}
if i > digit_start {
// Make sure it's not part of a larger word
let before_ok = start == 0 || !bytes[start - 1].is_ascii_alphanumeric();
let after_ok = i >= len || !bytes[i].is_ascii_alphanumeric();
if before_ok && after_ok {
let ident = text[start..i].to_string();
if seen.insert(ident.clone()) {
result.push(ident);
}
}
}
}
} else {
i += 1;
}
}
result
}
/// Extract markdown image references from text (e.g., `` or ``).
fn extract_image_references(text: &str) -> Vec<&str> {
let mut images = Vec::new();
let mut remaining = text;
while let Some(start) = remaining.find("![") {
if let Some(alt_end) = remaining[start + 2..].find("](") {
let paren_start = start + 2 + alt_end + 2;
if let Some(paren_end) = remaining[paren_start..].find(')') {
let full_end = paren_start + paren_end + 1;
images.push(&remaining[start..full_end]);
remaining = &remaining[full_end..];
continue;
}
}
remaining = &remaining[start + 2..];
}
images
}
/// If `new_description` would drop image references present in `original`, append them.
pub(crate) fn preserve_images(original: &str, new_description: &str) -> String {
let original_images = extract_image_references(original);
if original_images.is_empty() {
return new_description.to_string();
}
let mut missing: Vec<&str> = Vec::new();
for img in &original_images {
if !new_description.contains(img) {
missing.push(img);
}
}
if missing.is_empty() {
return new_description.to_string();
}
let mut result = new_description.to_string();
result.push_str("\n\n");
result.push_str(&missing.join("\n"));
result
}
/// Extract code-relevant search hints from issue title, description, and labels.
/// Returns terms that Claude should search for in the codebase.
fn extract_code_hints(title: &str, description: &str, labels: &[String]) -> Vec<String> {
let mut hints = Vec::new();
let combined = format!("{} {}", title, description);
// Extract file paths (e.g. src/foo.rs, Components/Bar.swift)
for word in combined.split_whitespace() {
let word = word.trim_matches(|c: char| {
!c.is_alphanumeric() && c != '/' && c != '.' && c != '_' && c != '-'
});
if (word.contains('/') && word.contains('.'))
|| word.ends_with(".rs")
|| word.ends_with(".ts")
|| word.ends_with(".swift")
{
hints.push(word.to_string());
}
}
// Extract backtick-quoted identifiers (e.g. `WorktreeManager`, `cleanup()`)
for cap in combined.split('`').collect::<Vec<_>>().chunks(2) {
if cap.len() == 2 && !cap[1].is_empty() && cap[1].len() < 80 {
hints.push(cap[1].trim().to_string());
}
}
// Extract PascalCase and snake_case identifiers from title
for word in title.split_whitespace() {
let word = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '_');
// PascalCase: at least 2 uppercase letters with lowercase between
let upper_count = word.chars().filter(|c| c.is_uppercase()).count();
if upper_count >= 2
&& word.len() >= 4
&& word.chars().next().is_some_and(|c| c.is_uppercase())
{
hints.push(word.to_string());
}
// snake_case
if word.contains('_')
&& word.chars().all(|c| c.is_alphanumeric() || c == '_')
&& word.len() >= 4
{
hints.push(word.to_string());
}
}
// Add labels as search terms
for label in labels {
if !label.is_empty() {
hints.push(label.clone());
}
}
// Deduplicate
hints.sort();
hints.dedup();
hints
}
/// Suggest up to 3 label names from the local catalog matching any unknown name as a substring.
/// Returns an empty vec if catalog can't be read or no candidates match.
fn suggest_label_names(db: &Database, workspace: &str, unknown: &[String]) -> Vec<String> {
let Ok(catalog) = db.list_labels(workspace) else { return Vec::new() };
let mut hits: Vec<String> = Vec::new();
for u in unknown {
let needle = u.to_lowercase();
for label in &catalog {
let hay = label.name.to_lowercase();
if (hay.contains(&needle) || needle.contains(&hay))
&& !hits.contains(&label.name)
{
hits.push(label.name.clone());
if hits.len() >= 3 {
return hits;
}
}
}
}
hits
}
#[derive(Clone)]
pub struct RectilinearMcp {
db: Database,
config: Config,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct ListWorkspacesArgs {}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct ListLabelsArgs {
/// Workspace name (required). Use list_workspaces to see available workspaces.
workspace: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct ListProjectsArgs {
/// Workspace name (required). Use list_workspaces to see available workspaces.
workspace: Option<String>,
/// Refresh projects and milestones from Linear before reading. Defaults to true.
refresh: Option<bool>,
/// Include archived projects. Defaults to false.
include_archived: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct GetProjectArgs {
/// Workspace name (required). Use list_workspaces to see available workspaces.
workspace: Option<String>,
/// Project UUID, slug, or name.
id: String,
/// Include every milestone and linked issue by importing the full project. Defaults to false.
include_issues: Option<bool>,
/// Refresh metadata from Linear before reading. Defaults to true.
refresh: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct CreateProjectArgs {
/// Workspace name (required). Use list_workspaces to see available workspaces.
workspace: Option<String>,
/// Project name.
name: String,
/// Team keys that own the project (for example ["ENG", "IOS"]).
teams: Vec<String>,
/// Short project summary.
description: Option<String>,
/// Detailed project content in Markdown.
content: Option<String>,
/// Linear project icon identifier.
icon: Option<String>,
/// Linear project color.
color: Option<String>,
/// Project status name or UUID.
status: Option<String>,
/// Project priority (0=no priority, 1=urgent, 2=high, 3=medium, 4=low).
priority: Option<i32>,
/// Project lead: "me" or a member name.
lead: Option<String>,
/// Start date in YYYY-MM-DD format.
start_date: Option<String>,
/// Target date in YYYY-MM-DD format.
target_date: Option<String>,
/// Project members: each value is "me" or a member name.
members: Option<Vec<String>>,
/// Project label names or UUIDs.
labels: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct UpdateProjectArgs {
/// Workspace name (required). Use list_workspaces to see available workspaces.
workspace: Option<String>,
/// Project UUID, slug, or current name.
id: String,
/// New project name.
name: Option<String>,
/// Replacement team keys.
teams: Option<Vec<String>>,
/// New short project summary. Pass an empty string to clear.
description: Option<String>,
/// New detailed project content. Pass an empty string to clear.
content: Option<String>,
/// New icon. Pass "none" to clear.
icon: Option<String>,
/// New color. Pass "none" to clear.
color: Option<String>,
/// New status name or UUID.
status: Option<String>,
/// New project priority.
priority: Option<i32>,
/// New lead. Pass "none" to clear.
lead: Option<String>,
/// New start date, or "none" to clear.
start_date: Option<String>,
/// New target date, or "none" to clear.
target_date: Option<String>,
/// Replacement project members.
members: Option<Vec<String>>,
/// Replacement project label names or UUIDs. Pass [] to clear.
labels: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct DeleteProjectArgs {
/// Workspace name (required). Use list_workspaces to see available workspaces.
workspace: Option<String>,
/// Project UUID, slug, or name.
id: String,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct ImportProjectArgs {
/// Workspace name (required). Use list_workspaces to see available workspaces.
workspace: Option<String>,
/// Project UUID, slug, or name.
id: String,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct ListProjectMilestonesArgs {
/// Workspace name (required). Use list_workspaces to see available workspaces.
workspace: Option<String>,
/// Owning project UUID, slug, or name.
project: String,
/// Refresh project and milestone metadata from Linear first. Defaults to true.
refresh: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct GetProjectMilestoneArgs {
/// Workspace name (required). Use list_workspaces to see available workspaces.
workspace: Option<String>,
/// Milestone UUID or name.
id: String,
/// Owning project UUID, slug, or name. Recommended when resolving a milestone by name.
project: Option<String>,
/// Include all linked issues by importing the full milestone. Defaults to false.
include_issues: Option<bool>,
/// Refresh metadata from Linear first. Defaults to true.
refresh: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct CreateProjectMilestoneArgs {
/// Workspace name (required). Use list_workspaces to see available workspaces.
workspace: Option<String>,
/// Owning project UUID, slug, or name.
project: String,
/// Milestone name.
name: String,
/// Milestone description.
description: Option<String>,
/// Target date in YYYY-MM-DD format.
target_date: Option<String>,
/// Position within the project.
sort_order: Option<f64>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct UpdateProjectMilestoneArgs {
/// Workspace name (required). Use list_workspaces to see available workspaces.
workspace: Option<String>,
/// Milestone UUID or name.
id: String,
/// Owning/new project UUID, slug, or name. Also disambiguates milestone names.
project: Option<String>,
/// New milestone name.
name: Option<String>,
/// New description. Pass an empty string to clear.
description: Option<String>,
/// New target date, or "none" to clear.
target_date: Option<String>,
/// New position within the project.
sort_order: Option<f64>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct DeleteProjectMilestoneArgs {
/// Workspace name (required). Use list_workspaces to see available workspaces.
workspace: Option<String>,
/// Milestone UUID or name.
id: String,
/// Owning project UUID, slug, or name. Recommended when resolving by name.
project: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct ImportProjectMilestoneArgs {
/// Workspace name (required). Use list_workspaces to see available workspaces.
workspace: Option<String>,
/// Milestone UUID or name.
id: String,
/// Owning project UUID, slug, or name. Recommended when resolving by name.
project: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct SearchArgs {
/// Workspace name (required). Use list_workspaces to see available workspaces.
workspace: Option<String>,
/// Search query text
query: String,
/// Filter by team key (e.g., "ENG")
team: Option<String>,
/// Filter by state name
state: Option<String>,
/// Search mode: "fts", "vector", or "hybrid"
mode: Option<String>,
/// Maximum number of results
limit: Option<usize>,
/// Filter to issues that have ALL of these labels (case-insensitive).
labels: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct FindDuplicatesArgs {
/// Workspace name (required). Use list_workspaces to see available workspaces.
workspace: Option<String>,
/// Title of the potential new issue
title: String,
/// Description of the potential new issue
description: Option<String>,
/// Filter by team key
team: Option<String>,
/// Minimum similarity threshold (0.0-1.0)
threshold: Option<f32>,
/// Maximum number of results
limit: Option<usize>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct GetIssueArgs {
/// Workspace name (required). Use list_workspaces to see available workspaces.
workspace: Option<String>,
/// Issue ID or identifier (e.g., "ENG-123")
id: String,
/// Whether to include comments
include_comments: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct CreateIssueArgs {
/// Workspace name (required). Use list_workspaces to see available workspaces.
workspace: Option<String>,
/// Team key (e.g., "ENG")
team: String,
/// Issue title
title: String,
/// Issue description
description: Option<String>,
/// Priority: 1=Urgent, 2=High, 3=Medium, 4=Low
priority: Option<i32>,
/// Parent issue identifier to create as sub-issue (e.g., "CUT-42")
parent: Option<String>,
/// Set labels by name (case-insensitive). Use list_labels to discover.
labels: Option<Vec<String>>,
/// Assignee. Pass "me" to assign to the authenticated user, or a name (case-insensitive).
assignee: Option<String>,
/// Set project by UUID, slug, or name.
project: Option<String>,
/// Set project milestone by UUID or name. Its project is inferred when omitted.
project_milestone: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct UpdateIssueArgs {
/// Workspace name (required). Use list_workspaces to see available workspaces.
workspace: Option<String>,
/// Issue ID or identifier
id: String,
/// New title
title: Option<String>,
/// New description
description: Option<String>,
/// New priority
priority: Option<i32>,
/// Set issue state by name (e.g., "Done", "Cancelled", "In Progress")
state: Option<String>,
/// Set labels by name (replaces all existing labels)
labels: Option<Vec<String>>,
/// Set project by name (or "none" to remove from project)
project: Option<String>,
/// Set project milestone by name/UUID (or "none" to remove from a milestone).
project_milestone: Option<String>,
/// Assignee. Pass "me" for self-assign, "none" to clear, or a name (case-insensitive).
assignee: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct AppendArgs {
/// Workspace name (required). Use list_workspaces to see available workspaces.
workspace: Option<String>,
/// Issue ID or identifier
id: String,
/// Comment text to add
comment: Option<String>,
/// Text to append to description
description: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct SyncTeamArgs {
/// Workspace name (required). Use list_workspaces to see available workspaces.
workspace: Option<String>,
/// Team key to sync
team: String,
/// Whether to do a full re-sync
full: Option<bool>,
/// Include archived Linear issues. Defaults to true for full syncs and false for incremental syncs.
include_archived: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct IssueContextArgs {
/// Workspace name (required). Use list_workspaces to see available workspaces.
workspace: Option<String>,
/// Issue ID or identifier
id: String,
/// Number of similar issues to return
similar_count: Option<usize>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct GetTriageQueueArgs {
/// Workspace name (required). Use list_workspaces to see available workspaces.
workspace: Option<String>,
/// Team key (e.g., "CUT")
team: String,
/// Max issues to return (default 10)
limit: Option<usize>,
/// Issue identifiers to skip (already triaged this session)
exclude: Option<Vec<String>>,
/// Randomize issue order instead of chronological (default false)
shuffle: Option<bool>,
/// Include completed/canceled issues (default false). Useful for archival prioritization.
include_completed: Option<bool>,
/// Filter to issues that have ALL of these labels (case-insensitive).
labels: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct MarkTriagedArgs {
/// Workspace name (required). Use list_workspaces to see available workspaces.
workspace: Option<String>,
/// Issue identifier (e.g., "CUT-42")
id: String,
/// New priority (1=Urgent, 2=High, 3=Medium, 4=Low)
priority: i32,
/// Improved title (optional)
title: Option<String>,
/// Improved description (optional)
description: Option<String>,
/// Triage comment explaining the decision (optional)
comment: Option<String>,
/// Set issue state (e.g., "Done", "Cancelled", "Duplicate", "Backlog"). Looked up by name for the issue's team.
state: Option<String>,
/// Set labels by name (replaces all existing labels)
labels: Option<Vec<String>>,
/// Set project by name (or "none" to remove from project)
project: Option<String>,
/// Set project milestone by name/UUID (or "none" to remove from a milestone).
project_milestone: Option<String>,
/// Assignee. Pass "me" for self-assign, "none" to clear, or a name (case-insensitive).
assignee: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct ManageRelationArgs {
/// Workspace name (required). Use list_workspaces to see available workspaces.
workspace: Option<String>,
/// Action: "add" or "remove"
action: String,
/// Source issue identifier (e.g., "CUT-42")
issue: String,
/// Related issue identifier (e.g., "CUT-99")
related_issue: String,
/// Relation type: "blocks", "blocked_by", "related", "duplicate"
relation_type: String,
}
#[tool(tool_box)]
impl RectilinearMcp {
pub fn new(db: Database, config: Config) -> Self {
Self { db, config }
}
#[tool(
name = "list_workspaces",
description = "List all configured workspaces. Use this to discover available workspace names before calling other tools."
)]
async fn list_workspaces(
&self,
#[tool(aggr)] _args: ListWorkspacesArgs,
) -> Result<String, String> {
let names = self.config.workspace_names();
let active = self.config.resolve_active_workspace().ok();
let mut workspaces = Vec::new();
for name in &names {
let ws = self
.config
.workspace_config(name)
.map_err(|e| e.to_string())?;
let db_info = self.db.get_workspace(name).map_err(|e| e.to_string())?;
workspaces.push(serde_json::json!({
"name": name,
"active": active.as_deref() == Some(name.as_str()),
"default_team": ws.default_team,
"org_name": db_info.as_ref().and_then(|w| w.display_name.clone()),
}));
}
serde_json::to_string_pretty(&serde_json::json!({
"workspaces": workspaces,
"instruction": "Pass the workspace name to all other tools."
}))
.map_err(|e| e.to_string())
}
#[tool(
name = "list_labels",
description = "List all labels in the workspace, grouped by parent. Pure local read — no Linear API call. If empty, run sync_team to refresh the catalog."
)]
async fn list_labels(
&self,
#[tool(aggr)] args: ListLabelsArgs,
) -> Result<String, String> {
let workspace = self.require_workspace(&args.workspace)?;
let labels = self.db.list_labels(&workspace).map_err(|e| e.to_string())?;
// Index by id for parent name lookup.
let by_id: std::collections::HashMap<&str, &str> =
labels.iter().map(|l| (l.id.as_str(), l.name.as_str())).collect();
// Group: top-level (no parent) and grouped-by-parent.
let mut top_level: Vec<&_> = labels.iter().filter(|l| l.parent_id.is_none()).collect();
top_level.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
let mut groups: std::collections::BTreeMap<String, Vec<&_>> = std::collections::BTreeMap::new();
for l in labels.iter().filter(|l| l.parent_id.is_some()) {
let parent_name = l.parent_id.as_deref()
.and_then(|pid| by_id.get(pid).copied())
.unwrap_or("(unknown group)")
.to_string();
groups.entry(parent_name).or_default().push(l);
}
for v in groups.values_mut() {
v.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
}
let payload = serde_json::json!({
"workspace": workspace,
"count": labels.len(),
"top_level": top_level.iter().map(|l| serde_json::json!({
"name": l.name,
"color": l.color,
})).collect::<Vec<_>>(),
"groups": groups.iter().map(|(parent, members)| serde_json::json!({
"parent": parent,
"labels": members.iter().map(|l| serde_json::json!({
"name": l.name,
"color": l.color,
})).collect::<Vec<_>>(),
})).collect::<Vec<_>>(),
"note": if labels.is_empty() {
"Catalog is empty. Run sync_team to populate labels."
} else { "" },
});
serde_json::to_string_pretty(&payload).map_err(|e| e.to_string())
}
#[tool(
name = "list_projects",
description = "List Linear projects with their status, dates, lead, teams, members, labels, progress, and other metadata. Refreshes the local project/milestone mirror by default."
)]
async fn list_projects(
&self,
#[tool(aggr)] args: ListProjectsArgs,
) -> Result<String, String> {
let workspace = self.require_workspace(&args.workspace)?;
if args.refresh.unwrap_or(true) {
let client = self.client_for_workspace(&workspace)?;
client
.sync_projects(&self.db, &workspace)
.await
.map_err(|error| error.to_string())?;
}
let projects = self
.db
.list_projects(&workspace, args.include_archived.unwrap_or(false))
.map_err(|error| error.to_string())?;
serde_json::to_string_pretty(&serde_json::json!({
"workspace": workspace,
"count": projects.len(),
"projects": projects,
}))
.map_err(|error| error.to_string())
}
#[tool(
name = "get_project",
description = "Get a Linear project and its milestones. Set include_issues=true to refresh and return the complete importable project bundle with every linked issue."
)]
async fn get_project(
&self,
#[tool(aggr)] args: GetProjectArgs,
) -> Result<String, String> {
let workspace = self.require_workspace(&args.workspace)?;
let client = self.client_for_workspace(&workspace)?;
if args.include_issues.unwrap_or(false) {
let bundle = client
.import_project(&self.db, &workspace, &args.id)
.await
.map_err(|error| error.to_string())?;
return serde_json::to_string_pretty(&bundle).map_err(|error| error.to_string());
}
if args.refresh.unwrap_or(true) {
client
.sync_projects(&self.db, &workspace)
.await
.map_err(|error| error.to_string())?;
}
let bundle = self
.db
.get_project_bundle(&workspace, &args.id)
.map_err(|error| error.to_string())?
.ok_or_else(|| format!("Project '{}' not found", args.id))?;
serde_json::to_string_pretty(&serde_json::json!({
"project": bundle.project,
"milestones": bundle.milestones,
}))
.map_err(|error| error.to_string())
}
#[tool(
name = "create_project",
description = "Create a Linear project with first-class status, priority, lead, team, membership, label, date, icon, color, summary, and detailed-content metadata."
)]
async fn create_project(
&self,
#[tool(aggr)] args: CreateProjectArgs,
) -> Result<String, String> {
let workspace = self.require_workspace(&args.workspace)?;
let client = self.client_for_workspace(&workspace)?;
let team_ids = self.resolve_team_ids(&client, &args.teams).await?;
let status_id = match args.status.as_deref() {
Some(status) => Some(
client
.get_project_status_id(status)
.await
.map_err(|error| error.to_string())?,
),
None => None,
};
let lead_id = match args.lead.as_deref() {
Some(lead) if lead.eq_ignore_ascii_case("none") => {
return Err("Cannot clear a lead while creating a project; omit lead instead.".into())
}
Some(lead) => Some(
client
.resolve_assignee_id(lead)
.await
.map_err(|error| error.to_string())?,
),
None => None,
};
let member_ids = match args.members.as_deref() {
Some(members) => Some(self.resolve_member_ids(&client, members).await?),
None => None,
};
let label_ids = match args.labels.as_deref() {
Some(labels) => Some(
client
.get_project_label_ids(labels)
.await
.map_err(|error| error.to_string())?,
),
None => None,
};
let input = crate::linear::CreateProjectInput {
name: args.name,
team_ids,
description: args.description,
content: args.content,
icon: args.icon,
color: args.color,
status_id,
priority: args.priority,
lead_id,
start_date: args.start_date,
target_date: args.target_date,
member_ids,
label_ids,
};
let project_id = client
.create_project(&input)
.await
.map_err(|error| error.to_string())?;
let project = client
.fetch_project(&project_id, &workspace)
.await
.map_err(|error| error.to_string())?;
self.db
.upsert_project(&project)
.map_err(|error| error.to_string())?;
serde_json::to_string_pretty(&serde_json::json!({
"status": "created",
"project": project,
}))
.map_err(|error| error.to_string())
}
#[tool(
name = "update_project",
description = "Update a Linear project's metadata. Empty strings clear descriptions/content; use 'none' to clear nullable lead, date, icon, or color fields."
)]
async fn update_project(
&self,
#[tool(aggr)] args: UpdateProjectArgs,
) -> Result<String, String> {
let workspace = self.require_workspace(&args.workspace)?;
let client = self.client_for_workspace(&workspace)?;
let project_id = self.resolve_project_id(&client, &workspace, &args.id).await?;
let team_ids = match args.teams.as_deref() {
Some(teams) => Some(self.resolve_team_ids(&client, teams).await?),
None => None,
};
let status_id = match args.status.as_deref() {
Some(status) if status.eq_ignore_ascii_case("none") => {
return Err("A project must have a status; choose a status instead of 'none'.".into())
}
Some(status) => Some(
client
.get_project_status_id(status)
.await
.map_err(|error| error.to_string())?,
),
None => None,
};
let lead_id = match args.lead.as_deref() {
Some(lead) => Some(
client
.resolve_assignee_id(lead)
.await
.map_err(|error| error.to_string())?,
),
None => None,
};
let member_ids = match args.members.as_deref() {
Some(members) => Some(self.resolve_member_ids(&client, members).await?),
None => None,
};
let label_ids = match args.labels.as_deref() {
Some(labels) => Some(
client
.get_project_label_ids(labels)
.await
.map_err(|error| error.to_string())?,
),
None => None,
};
let input = crate::linear::UpdateProjectInput {
name: args.name,
team_ids,
description: args.description,
content: args.content,
icon: args.icon,
color: args.color,
status_id,
priority: args.priority,
lead_id,
start_date: args.start_date,
target_date: args.target_date,
member_ids,
label_ids,
};
client
.update_project(&project_id, &input)
.await
.map_err(|error| error.to_string())?;
let project = client
.fetch_project(&project_id, &workspace)
.await
.map_err(|error| error.to_string())?;
self.db
.upsert_project(&project)
.map_err(|error| error.to_string())?;
serde_json::to_string_pretty(&serde_json::json!({
"status": "updated",
"project": project,
}))
.map_err(|error| error.to_string())
}
#[tool(
name = "delete_project",
description = "Delete (archive) a Linear project and remove its cached project/milestone hierarchy from Rectilinear. Linked issues remain cached."
)]
async fn delete_project(
&self,
#[tool(aggr)] args: DeleteProjectArgs,
) -> Result<String, String> {
let workspace = self.require_workspace(&args.workspace)?;
let client = self.client_for_workspace(&workspace)?;
let project_id = self.resolve_project_id(&client, &workspace, &args.id).await?;
client
.delete_project(&project_id)
.await
.map_err(|error| error.to_string())?;
self.db
.delete_project_local(&project_id)
.map_err(|error| error.to_string())?;
Ok(serde_json::json!({
"status": "deleted",
"project_id": project_id,
})
.to_string())
}
#[tool(
name = "import_project",
description = "Import a complete Linear project into Rectilinear and return one portable bundle containing project metadata, ordered milestones, and every linked issue across teams."
)]
async fn import_project(
&self,
#[tool(aggr)] args: ImportProjectArgs,
) -> Result<String, String> {
let workspace = self.require_workspace(&args.workspace)?;
let client = self.client_for_workspace(&workspace)?;
let bundle = client
.import_project(&self.db, &workspace, &args.id)
.await
.map_err(|error| error.to_string())?;
serde_json::to_string_pretty(&bundle).map_err(|error| error.to_string())
}
#[tool(
name = "list_project_milestones",
description = "List ordered milestones and progress metadata for a Linear project. Refreshes the project hierarchy from Linear by default."
)]
async fn list_project_milestones(
&self,
#[tool(aggr)] args: ListProjectMilestonesArgs,
) -> Result<String, String> {
let workspace = self.require_workspace(&args.workspace)?;
let client = self.client_for_workspace(&workspace)?;
if args.refresh.unwrap_or(true) {
client
.sync_projects(&self.db, &workspace)
.await
.map_err(|error| error.to_string())?;
}
let project_id = self
.resolve_project_id(&client, &workspace, &args.project)
.await?;
let milestones = self
.db
.list_project_milestones(&project_id)
.map_err(|error| error.to_string())?;
serde_json::to_string_pretty(&serde_json::json!({
"project_id": project_id,
"count": milestones.len(),
"milestones": milestones,
}))
.map_err(|error| error.to_string())
}
#[tool(
name = "get_project_milestone",
description = "Get a Linear project milestone. Set include_issues=true to refresh and return the complete importable milestone bundle with every linked issue."
)]
async fn get_project_milestone(
&self,
#[tool(aggr)] args: GetProjectMilestoneArgs,
) -> Result<String, String> {
let workspace = self.require_workspace(&args.workspace)?;
let client = self.client_for_workspace(&workspace)?;
let project_id = match args.project.as_deref() {
Some(project) => Some(
self.resolve_project_id(&client, &workspace, project)
.await?,
),
None => None,
};
if args.include_issues.unwrap_or(false) {
let bundle = client
.import_project_milestone(
&self.db,
&workspace,
project_id.as_deref(),
&args.id,
)
.await
.map_err(|error| error.to_string())?;
return serde_json::to_string_pretty(&bundle).map_err(|error| error.to_string());
}
if args.refresh.unwrap_or(true) {
client
.sync_projects(&self.db, &workspace)
.await
.map_err(|error| error.to_string())?;
}
let milestone = self
.db
.get_project_milestone(&workspace, &args.id, project_id.as_deref())
.map_err(|error| error.to_string())?
.ok_or_else(|| format!("Project milestone '{}' not found", args.id))?;
serde_json::to_string_pretty(&milestone).map_err(|error| error.to_string())
}
#[tool(
name = "create_project_milestone",
description = "Create a milestone within a Linear project, including description, target date, and ordering metadata."
)]
async fn create_project_milestone(
&self,
#[tool(aggr)] args: CreateProjectMilestoneArgs,
) -> Result<String, String> {
let workspace = self.require_workspace(&args.workspace)?;
let client = self.client_for_workspace(&workspace)?;
let project_id = self
.resolve_project_id(&client, &workspace, &args.project)
.await?;
let input = crate::linear::CreateProjectMilestoneInput {
project_id: project_id.clone(),
name: args.name,
description: args.description,
target_date: args.target_date,
sort_order: args.sort_order,
};
let milestone_id = client
.create_project_milestone(&input)
.await
.map_err(|error| error.to_string())?;
self.cache_project_and_milestone(&client, &workspace, &milestone_id)
.await?;
let milestone = self
.db
.get_project_milestone(&workspace, &milestone_id, Some(&project_id))
.map_err(|error| error.to_string())?
.ok_or_else(|| "Created milestone was not cached".to_string())?;
serde_json::to_string_pretty(&serde_json::json!({
"status": "created",
"milestone": milestone,
}))
.map_err(|error| error.to_string())
}
#[tool(
name = "update_project_milestone",
description = "Update or move a Linear project milestone. Empty description clears it; use 'none' to clear the target date."
)]
async fn update_project_milestone(
&self,
#[tool(aggr)] args: UpdateProjectMilestoneArgs,
) -> Result<String, String> {
let workspace = self.require_workspace(&args.workspace)?;
let client = self.client_for_workspace(&workspace)?;
let project_id = match args.project.as_deref() {
Some(project) => Some(
self.resolve_project_id(&client, &workspace, project)
.await?,
),
None => None,
};
let milestone_id = self
.resolve_milestone_id(
&client,
&workspace,
project_id.as_deref(),
&args.id,
)
.await?;
let input = crate::linear::UpdateProjectMilestoneInput {
project_id,
name: args.name,
description: args.description,
target_date: args.target_date,
sort_order: args.sort_order,
};
client
.update_project_milestone(&milestone_id, &input)
.await
.map_err(|error| error.to_string())?;
self.cache_project_and_milestone(&client, &workspace, &milestone_id)
.await?;
let milestone = self
.db
.get_project_milestone(&workspace, &milestone_id, None)
.map_err(|error| error.to_string())?
.ok_or_else(|| "Updated milestone was not cached".to_string())?;
serde_json::to_string_pretty(&serde_json::json!({
"status": "updated",
"milestone": milestone,
}))
.map_err(|error| error.to_string())
}
#[tool(
name = "delete_project_milestone",
description = "Delete a Linear project milestone and remove it from Rectilinear's local hierarchy. Linked issues remain cached."
)]
async fn delete_project_milestone(
&self,
#[tool(aggr)] args: DeleteProjectMilestoneArgs,
) -> Result<String, String> {
let workspace = self.require_workspace(&args.workspace)?;
let client = self.client_for_workspace(&workspace)?;
let project_id = match args.project.as_deref() {
Some(project) => Some(
self.resolve_project_id(&client, &workspace, project)
.await?,
),
None => None,
};
let milestone_id = self
.resolve_milestone_id(
&client,
&workspace,
project_id.as_deref(),
&args.id,
)
.await?;
client
.delete_project_milestone(&milestone_id)
.await
.map_err(|error| error.to_string())?;
self.db
.delete_project_milestone_local(&milestone_id)
.map_err(|error| error.to_string())?;
Ok(serde_json::json!({
"status": "deleted",
"milestone_id": milestone_id,
})
.to_string())
}
#[tool(
name = "import_project_milestone",
description = "Import a complete Linear milestone into Rectilinear and return one portable bundle containing its project metadata, milestone metadata, and every linked issue."
)]
async fn import_project_milestone(
&self,
#[tool(aggr)] args: ImportProjectMilestoneArgs,
) -> Result<String, String> {
let workspace = self.require_workspace(&args.workspace)?;
let client = self.client_for_workspace(&workspace)?;
let project_id = match args.project.as_deref() {
Some(project) => Some(
self.resolve_project_id(&client, &workspace, project)
.await?,
),
None => None,
};
let bundle = client
.import_project_milestone(
&self.db,
&workspace,
project_id.as_deref(),
&args.id,
)
.await
.map_err(|error| error.to_string())?;
serde_json::to_string_pretty(&bundle).map_err(|error| error.to_string())
}
#[tool(
name = "search_issues",
description = "Search Linear issues using hybrid FTS + vector search. Supports filtering by team and state."
)]
async fn search_issues(&self, #[tool(aggr)] args: SearchArgs) -> Result<String, String> {
let workspace = self.require_workspace(&args.workspace)?;
let mode: SearchMode = args
.mode
.as_deref()
.unwrap_or("hybrid")
.parse()
.map_err(|e: anyhow::Error| e.to_string())?;
let limit = args.limit.unwrap_or(self.config.search.default_limit);
let embedder = if mode != SearchMode::Fts {
Embedder::new(&self.config).ok()
} else {
None
};
let label_ids = if let Some(ref names) = args.labels {
let (resolved, unknown) = self.db
.resolve_label_ids_local(&workspace, names)
.map_err(|e| e.to_string())?;
if !unknown.is_empty() {
if resolved.is_empty()
&& self.db.list_labels(&workspace).map_err(|e| e.to_string())?.is_empty()
{
return Err(format!(
"No labels synced yet for workspace '{}'. Run sync_team first.",
workspace
));
}
let suggestions = suggest_label_names(&self.db, &workspace, &unknown);
return Err(format!(
"Label{} {} not found. {}Run list_labels for the full set.",
if unknown.len() == 1 { "" } else { "s" },
unknown.iter().map(|s| format!("'{}'", s)).collect::<Vec<_>>().join(", "),
if suggestions.is_empty() { String::new() }
else { format!("Did you mean: {}? ", suggestions.join(", ")) }
));
}
Some(resolved)
} else {
None
};
let results = search::search(
&self.db,
search::SearchParams {
query: &args.query,
mode,
team_key: args.team.as_deref(),
state_filter: args.state.as_deref(),
label_ids: label_ids.as_deref(),
limit,
embedder: embedder.as_ref(),
rrf_k: self.config.search.rrf_k,
workspace_id: &workspace,
},
)
.await
.map_err(|e| e.to_string())?;
serde_json::to_string_pretty(&results).map_err(|e| e.to_string())
}
#[tool(
name = "find_duplicates",
description = "Find potential duplicate issues. Provide a title and optional description to find similar existing issues with similarity scores."
)]
async fn find_duplicates(
&self,
#[tool(aggr)] args: FindDuplicatesArgs,
) -> Result<String, String> {
let workspace = self.require_workspace(&args.workspace)?;
let embedder = Embedder::new(&self.config).map_err(|e| e.to_string())?;
let search_text = if let Some(ref desc) = args.description {
format!("{}\n\n{}", args.title, desc)
} else {
args.title.clone()
};
let threshold = args
.threshold
.unwrap_or(self.config.search.duplicate_threshold);
let limit = args.limit.unwrap_or(10);
let results = search::find_duplicates(
&self.db,
&search_text,
args.team.as_deref(),
threshold,
limit,
&embedder,
self.config.search.rrf_k,
&workspace,
)
.await
.map_err(|e| e.to_string())?;
serde_json::to_string_pretty(&results).map_err(|e| e.to_string())
}
#[tool(
name = "get_issue",
description = "Get full details of an issue by ID or identifier (e.g., 'ENG-123'). Includes description, state, priority, labels, relations, and optionally comments. With include_comments=true, Rectilinear returns comments plus comments_status/comments_synced_at/comments_sync_error so an empty comments array is explicit: 'none_found' means Linear returned no comments, 'not_synced' means comments have not been fetched, 'permission_denied' or 'unavailable' means Linear could not provide them. Falls back to fetching from Linear API, including archived issues, if not found locally."
)]
async fn get_issue(&self, #[tool(aggr)] args: GetIssueArgs) -> Result<String, String> {
let workspace = self.require_workspace(&args.workspace)?;
let issue = match self.db.get_issue(&args.id).map_err(|e| e.to_string())? {
Some(issue) => issue,
None => {
// Not found locally — try fetching from Linear API by identifier
let client = self.client_for_workspace(&workspace)?;
let result = client
.fetch_issue_by_identifier(&args.id)
.await
.map_err(|e| e.to_string())?;
match result {
Some((mut issue, relations, label_ids)) => {
issue.workspace_id = workspace.clone();
self.db.upsert_issue(&issue).map_err(|e| e.to_string())?;
self.db
.upsert_relations(&issue.id, &relations)
.map_err(|e| e.to_string())?;
self.db
.replace_issue_labels(&issue.id, &label_ids)
.map_err(|e| e.to_string())?;
issue
}
None => return Err(format!("Issue '{}' not found", args.id)),
}
}
};
let mut value = serde_json::to_value(&issue).map_err(|e| e.to_string())?;
let relations = self
.db
.get_relations_enriched(&issue.id)
.map_err(|e| e.to_string())?;
if !relations.is_empty() {
value["relations"] = serde_json::to_value(&relations).map_err(|e| e.to_string())?;
}
if args.include_comments.unwrap_or(false) {
self.ensure_comments_synced_if_needed(&workspace, &issue.id)
.await?;
attach_comments_payload(&mut value, &self.db, &issue.id)?;
}
enrich_with_issue_links(&mut value, &self.db);
serde_json::to_string_pretty(&value).map_err(|e| e.to_string())
}
#[tool(
name = "create_issue",
description = "Create a new issue in Linear. Specify team (key like 'ENG'), title, and optionally description, priority (1=Urgent, 2=High, 3=Medium, 4=Low), labels (list of names), assignee ('me' for self-assign, or a user's display name), project, and project milestone.
IMPORTANT — Before calling this tool, you MUST:
1. **Disambiguate the request.** Ask the user 2-4 clarifying questions to sharpen scope, acceptance criteria, and edge cases. Think like a principal engineer: what assumptions are you making? What could go wrong? What's in vs. out of scope? Do not create the issue until the user has answered.
2. **Check for duplicates.** Call find_duplicates with the intended title/description to verify this issue doesn't already exist. If a match is found (>0.8 similarity), show it to the user and ask whether to proceed, update the existing issue, or cancel.
3. **Write a clear title and description.** The title should be imperative and specific (e.g. 'Add rate limiting to /api/upload endpoint' not 'rate limiting'). The description should include: what the desired behavior is, why it matters, and any constraints or acceptance criteria surfaced during disambiguation."
)]
async fn create_issue(&self, #[tool(aggr)] args: CreateIssueArgs) -> Result<String, String> {
let workspace = self.require_workspace(&args.workspace)?;
let client = self.client_for_workspace(&workspace)?;
let team_id = client
.get_team_id(&args.team)
.await
.map_err(|e| e.to_string())?;
let parent_id = if let Some(ref parent_ident) = args.parent {
let parent = self
.db
.get_issue(parent_ident)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("Parent issue '{}' not found", parent_ident))?;
Some(parent.id)
} else {
None
};
let label_ids: Vec<String> = if let Some(ref names) = args.labels {
self.resolve_labels_for_mutation(&workspace, &client, names).await?
} else {
Vec::new()
};
// Resolve assignee.
let assignee_id: Option<String> = if let Some(ref a) = args.assignee {
if a.eq_ignore_ascii_case("none") {
return Err("Cannot use 'none' on create_issue; omit the parameter to leave unassigned.".to_string());
}
Some(client.resolve_assignee_id(a).await.map_err(|e| e.to_string())?)
} else {
None
};
let mut project_id = match args.project.as_deref() {
Some(value) => Some(self.resolve_project_id(&client, &workspace, value).await?),
None => None,
};
let project_milestone_id = match args.project_milestone.as_deref() {
Some(value) => {
let milestone_id = self
.resolve_milestone_id(
&client,
&workspace,
project_id.as_deref(),
value,
)
.await?;
let milestone = client
.fetch_project_milestone(&milestone_id, &workspace)
.await
.map_err(|error| error.to_string())?;
if project_id.is_none() {
project_id = Some(milestone.project_id);
}
Some(milestone_id)
}
None => None,
};
let (issue_id, identifier) = client
.create_issue(crate::linear::CreateIssueInput {
team_id: &team_id,
title: &args.title,
description: args.description.as_deref(),
priority: args.priority,
label_ids: &label_ids,
assignee_id: assignee_id.as_deref(),
parent_id: parent_id.as_deref(),
project_id: project_id.as_deref(),
project_milestone_id: project_milestone_id.as_deref(),
})
.await
.map_err(|e| e.to_string())?;
let (issue, relations, label_ids) = client
.fetch_single_issue(&issue_id)
.await
.map_err(|e| e.to_string())?;
self.db.upsert_issue(&issue).map_err(|e| e.to_string())?;
self.db
.upsert_relations(&issue.id, &relations)
.map_err(|e| e.to_string())?;
self.db
.replace_issue_labels(&issue.id, &label_ids)
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"id": issue_id,
"identifier": identifier,
"url": issue.url,
"status": "created"
})
.to_string())
}
#[tool(
name = "update_issue",
description = "Update an existing Linear issue. Provide the issue ID/identifier and fields to update. Prefer append_to_issue for adding context. Image references in the original description are automatically preserved when updating."
)]
async fn update_issue(&self, #[tool(aggr)] args: UpdateIssueArgs) -> Result<String, String> {
let workspace = self.require_workspace(&args.workspace)?;
let issue = self
.db
.get_issue(&args.id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("Issue '{}' not found", args.id))?;
let client = self.client_for_workspace(&workspace)?;
let state_id = if let Some(ref state_name) = args.state {
Some(
client
.get_state_id(&issue.team_key, state_name)
.await
.map_err(|e| e.to_string())?,
)
} else {
None
};
let label_ids = if let Some(ref label_names) = args.labels {
Some(self.resolve_labels_for_mutation(&workspace, &client, label_names).await?)
} else {
None
};
let mut project_id = if let Some(ref project_name) = args.project {
if project_name.eq_ignore_ascii_case("none") {
Some(String::new()) // Empty string removes project in Linear
} else {
Some(
client
.get_project_id(project_name)
.await
.map_err(|e| e.to_string())?,
)
}
} else {
None
};
let project_milestone_id = if let Some(ref milestone_name) = args.project_milestone {
if milestone_name.eq_ignore_ascii_case("none") {
Some(String::new())
} else {
if project_id.as_deref() == Some("") {
return Err("Cannot assign a milestone while removing the issue's project.".into());
}
let owning_project_id = project_id
.as_deref()
.filter(|id| !id.is_empty())
.or(issue.project_id.as_deref());
let milestone_id = client
.find_project_milestone(owning_project_id, milestone_name)
.await
.map_err(|e| e.to_string())?;
let milestone = client
.fetch_project_milestone(&milestone_id, &workspace)
.await
.map_err(|e| e.to_string())?;
if project_id.is_none() && issue.project_id.as_deref() != Some(&milestone.project_id) {
project_id = Some(milestone.project_id);
}
Some(milestone_id)
}
} else {
None
};
let assignee_id: Option<String> = if let Some(ref a) = args.assignee {
Some(client.resolve_assignee_id(a).await.map_err(|e| e.to_string())?)
} else {
None
};
// If updating description, re-fetch from Linear to preserve any image references
let safe_description = if args.description.is_some() {
let (latest, _, _) = client
.fetch_single_issue(&issue.id)
.await
.map_err(|e| e.to_string())?;
args.description
.as_ref()
.map(|new_desc| match &latest.description {
Some(original) => preserve_images(original, new_desc),
None => new_desc.clone(),
})
} else {
None
};
client
.update_issue(
&issue.id,
crate::linear::UpdateIssueInput {
title: args.title.as_deref(),
description: safe_description.as_deref(),
priority: args.priority,
state_id: state_id.as_deref(),
label_ids: label_ids.as_deref(),
project_id: project_id.as_deref(),
assignee_id: assignee_id.as_deref(),
project_milestone_id: project_milestone_id.as_deref(),
},
)
.await
.map_err(|e| e.to_string())?;
let (mut updated, relations, label_ids) = client
.fetch_single_issue(&issue.id)
.await
.map_err(|e| e.to_string())?;
updated.workspace_id = workspace.clone();
self.db.upsert_issue(&updated).map_err(|e| e.to_string())?;
self.db
.upsert_relations(&updated.id, &relations)
.map_err(|e| e.to_string())?;
self.db
.replace_issue_labels(&updated.id, &label_ids)
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"identifier": issue.identifier,
"url": issue.url,
"status": "updated"
})
.to_string())
}
#[tool(
name = "append_to_issue",
description = "Add a comment to an issue or append text to its description."
)]
async fn append_to_issue(&self, #[tool(aggr)] args: AppendArgs) -> Result<String, String> {
let workspace = self.require_workspace(&args.workspace)?;
let issue = self
.db
.get_issue(&args.id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("Issue '{}' not found", args.id))?;
let client = self.client_for_workspace(&workspace)?;
let mut actions: Vec<&str> = Vec::new();
if let Some(ref comment_text) = args.comment {
client
.add_comment(&issue.id, comment_text)
.await
.map_err(|e| e.to_string())?;
let _ = client
.sync_issue_comments(&self.db, &issue.id, &workspace)
.await;
actions.push("comment_added");
}
if let Some(ref desc_text) = args.description {
let new_desc = match &issue.description {
Some(existing) => format!("{}\n\n{}", existing, desc_text),
None => desc_text.clone(),
};
client
.update_issue(
&issue.id,
crate::linear::UpdateIssueInput {
description: Some(&new_desc),
..Default::default()
},
)
.await
.map_err(|e| e.to_string())?;
actions.push("description_updated");
}
let (mut updated, relations, label_ids) = client
.fetch_single_issue(&issue.id)
.await
.map_err(|e| e.to_string())?;
updated.workspace_id = workspace.clone();
self.db.upsert_issue(&updated).map_err(|e| e.to_string())?;
self.db
.upsert_relations(&updated.id, &relations)
.map_err(|e| e.to_string())?;
self.db
.replace_issue_labels(&updated.id, &label_ids)
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"identifier": issue.identifier,
"actions": actions
})
.to_string())
}
#[tool(
name = "sync_team",
description = "Sync issues from Linear for a specific team. Use full=true for a complete re-sync; full syncs include archived issues by default so completed/canceled archived evidence issues are refreshed. Each synced issue also refreshes Linear comments and records comment sync diagnostics for get_issue(include_comments=true)."
)]
async fn sync_team(&self, #[tool(aggr)] args: SyncTeamArgs) -> Result<String, String> {
let workspace = self.require_workspace(&args.workspace)?;
let client = self.client_for_workspace(&workspace)?;
let full = args.full.unwrap_or(false);
let include_archived = args.include_archived.unwrap_or(full);
let count = client
.sync_team(&self.db, &args.team, &workspace, full, include_archived, None)
.await
.map_err(|e| e.to_string())?;
let total = self
.db
.count_issues(Some(&args.team), &workspace)
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"synced": count,
"total": total,
"team": args.team,
"include_archived": include_archived
})
.to_string())
}
#[tool(
name = "issue_context",
description = "Get an issue along with its N most similar issues, useful for understanding context and related work."
)]
async fn issue_context(&self, #[tool(aggr)] args: IssueContextArgs) -> Result<String, String> {
let workspace = self.require_workspace(&args.workspace)?;
let issue = self
.db
.get_issue(&args.id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("Issue '{}' not found", args.id))?;
let similar_count = args.similar_count.unwrap_or(5);
let search_text = format!(
"{}\n\n{}",
issue.title,
issue.description.as_deref().unwrap_or("")
);
let similar = if let Ok(embedder) = Embedder::new(&self.config) {
search::find_duplicates(
&self.db,
&search_text,
Some(&issue.team_key),
0.3,
similar_count + 1,
&embedder,
self.config.search.rrf_k,
&workspace,
)
.await
.unwrap_or_default()
.into_iter()
.filter(|r| r.issue_id != issue.id)
.take(similar_count)
.collect::<Vec<_>>()
} else {
Vec::new()
};
self.ensure_comments_synced_if_needed(&workspace, &issue.id)
.await?;
let relations = self
.db
.get_relations_enriched(&issue.id)
.map_err(|e| e.to_string())?;
let issue_id = issue.id.clone();
let mut result = serde_json::json!({
"issue": issue,
"similar_issues": similar,
"relations": relations,
});
attach_comments_payload(&mut result, &self.db, &issue_id)?;
enrich_with_issue_links(&mut result, &self.db);
serde_json::to_string_pretty(&result).map_err(|e| e.to_string())
}
#[tool(
name = "get_triage_queue",
description = "Get a batch of unprioritized issues for triage. Returns enriched issues with similar issues and code_search_hints. IMPORTANT: For each issue, BEFORE presenting it to the user, you MUST search the codebase using the code_search_hints (via Grep, Glob, Read, or Cuttlefish MCP tools like get_symbols/find_references). Spend 2-4 tool calls per issue exploring relevant code, then include your findings when asking the user questions."
)]
async fn get_triage_queue(
&self,
#[tool(aggr)] args: GetTriageQueueArgs,
) -> Result<String, String> {
let workspace = self.require_workspace(&args.workspace)?;
// Incremental sync to pick up changes made by other users
if let Ok(client) = self.client_for_workspace(&workspace) {
let _ = client
.sync_team(&self.db, &args.team, &workspace, false, false, None)
.await;
}
let label_ids = if let Some(ref names) = args.labels {
let (resolved, unknown) = self.db
.resolve_label_ids_local(&workspace, names)
.map_err(|e| e.to_string())?;
if !unknown.is_empty() {
if resolved.is_empty()
&& self.db.list_labels(&workspace).map_err(|e| e.to_string())?.is_empty()
{
return Err(format!(
"No labels synced yet for workspace '{}'. Run sync_team first.",
workspace
));
}
let suggestions = suggest_label_names(&self.db, &workspace, &unknown);
return Err(format!(
"Label{} {} not found. {}Run list_labels for the full set.",
if unknown.len() == 1 { "" } else { "s" },
unknown.iter().map(|s| format!("'{}'", s)).collect::<Vec<_>>().join(", "),
if suggestions.is_empty() { String::new() }
else { format!("Did you mean: {}? ", suggestions.join(", ")) }
));
}
Some(resolved)
} else {
None
};
let all_issues = self
.db
.get_unprioritized_issues_filtered(
Some(&args.team),
args.include_completed.unwrap_or(false),
&workspace,
label_ids.as_deref(),
)
.map_err(|e| e.to_string())?;
let exclude_set: std::collections::HashSet<&str> = args
.exclude
.as_ref()
.map(|v| v.iter().map(|s| s.as_str()).collect())
.unwrap_or_default();
let filtered: Vec<_> = all_issues
.into_iter()
.filter(|i| !exclude_set.contains(i.identifier.as_str()))
.collect();
let limit = args.limit.unwrap_or(10);
let batch: Vec<_> = if args.shuffle.unwrap_or(false) {
use rand::seq::SliceRandom;
let mut indices: Vec<usize> = (0..filtered.len()).collect();
indices.shuffle(&mut rand::rng());
indices
.into_iter()
.take(limit)
.map(|i| &filtered[i])
.collect()
} else {
filtered.iter().take(limit).collect()
};
let total_remaining = filtered.len();
let embedder = Embedder::new(&self.config).ok();
let mut enriched = Vec::new();
for issue in &batch {
let description = issue.description.as_deref().map(|d| {
if d.len() > 2000 {
let mut end = 2000;
while end > 0 && !d.is_char_boundary(end) {
end -= 1;
}
&d[..end]
} else {
d
}
});
let similar = if let Some(ref embedder) = embedder {
let search_text = format!("{}\n\n{}", issue.title, description.unwrap_or(""));
search::find_duplicates(
&self.db,
&search_text,
Some(&args.team),
0.3,
4,
embedder,
self.config.search.rrf_k,
&workspace,
)
.await
.unwrap_or_default()
.into_iter()
.filter(|r| r.issue_id != issue.id)
.take(3)
.collect::<Vec<_>>()
} else {
Vec::new()
};
let relations = self
.db
.get_relations_enriched(&issue.id)
.unwrap_or_default();
// Extract search hints from title and description for code exploration
let code_search_hints =
extract_code_hints(&issue.title, description.unwrap_or(""), &issue.labels());
enriched.push(serde_json::json!({
"identifier": issue.identifier,
"url": issue.url,
"title": issue.title,
"description": description,
"state_name": issue.state_name,
"assignee_name": issue.assignee_name,
"project_name": issue.project_name,
"labels": issue.labels(),
"created_at": issue.created_at,
"similar_issues": similar,
"relations": relations,
"code_search_hints": code_search_hints,
}));
}
let mut result = serde_json::json!({
"instruction": "IMPORTANT: For each issue below, BEFORE asking the user any questions, search the codebase using the code_search_hints. Use Grep, Glob, Read, or Cuttlefish MCP tools (get_symbols, find_references) to understand the current code state. Then present your code findings alongside the issue summary. Always include the issue's Linear URL as a clickable markdown link [IDENTIFIER](url). Assume the perspective of a principal staff software engineer who has been tasked to implement this issue. Ask 2-4 thoughtful clarifying questions that would help elucidate any ambiguity or uncertainty in the issue description — the kind of questions an experienced engineer asks before writing code.",
"queue": enriched,
"total_remaining": total_remaining,
"team": args.team,
});
enrich_with_issue_links(&mut result, &self.db);
serde_json::to_string_pretty(&result).map_err(|e| e.to_string())
}
#[tool(
name = "mark_triaged",
description = "Mark an issue as triaged by setting priority and optionally updating title, description, and adding a triage comment. Combines update + comment into one call. Prefer using the comment field over description for adding context — description updates risk losing formatting. Image references in the original description are automatically preserved."
)]
async fn mark_triaged(&self, #[tool(aggr)] args: MarkTriagedArgs) -> Result<String, String> {
let workspace = self.require_workspace(&args.workspace)?;
if args.priority < 1 || args.priority > 4 {
return Err("Priority must be 1 (Urgent), 2 (High), 3 (Medium), or 4 (Low)".into());
}
// Resolve from local DB to get the Linear UUID
let local_issue = self
.db
.get_issue(&args.id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("Issue '{}' not found", args.id))?;
let client = self.client_for_workspace(&workspace)?;
// Re-fetch from Linear to get the latest version
let (issue, issue_relations, issue_label_ids) = client
.fetch_single_issue(&local_issue.id)
.await
.map_err(|e| e.to_string())?;
self.db.upsert_issue(&issue).map_err(|e| e.to_string())?;
self.db
.upsert_relations(&issue.id, &issue_relations)
.map_err(|e| e.to_string())?;
self.db
.replace_issue_labels(&issue.id, &issue_label_ids)
.map_err(|e| e.to_string())?;
// If someone else already prioritized it, let the caller know
if issue.priority != 0 {
return Ok(serde_json::json!({
"identifier": issue.identifier,
"url": issue.url,
"status": "already_triaged",
"current_priority": issue.priority,
"current_priority_label": issue.priority_label(),
"message": format!(
"{} was already prioritized as {} — skipping",
issue.identifier, issue.priority_label()
),
})
.to_string());
}
// Flag if the issue was modified since we last saw it
let was_modified = issue.content_hash != local_issue.content_hash;
if was_modified {
let mut changes = Vec::new();
if issue.title != local_issue.title {
changes.push(format!(
"title changed: \"{}\" → \"{}\"",
local_issue.title, issue.title
));
}
if issue.description != local_issue.description {
changes.push("description was updated".to_string());
}
if issue.state_name != local_issue.state_name {
changes.push(format!(
"state changed: {} → {}",
local_issue.state_name, issue.state_name
));
}
if issue.assignee_name != local_issue.assignee_name {
changes.push(format!(
"assignee changed: {} → {}",
local_issue.assignee_name.as_deref().unwrap_or("unassigned"),
issue.assignee_name.as_deref().unwrap_or("unassigned")
));
}
// Re-embed with the updated content
self.reembed_issue(&issue).await;
return Ok(serde_json::json!({
"identifier": issue.identifier,
"url": issue.url,
"status": "modified_since_queued",
"changes": changes,
"current_title": issue.title,
"current_description": issue.description,
"current_state": issue.state_name,
"message": format!(
"{} was modified since the queue was fetched — review the latest version before triaging",
issue.identifier
),
})
.to_string());
}
// Resolve state name to ID if provided
let state_id = if let Some(ref state_name) = args.state {
Some(
client
.get_state_id(&issue.team_key, state_name)
.await
.map_err(|e| e.to_string())?,
)
} else {
None
};
let label_ids = if let Some(ref label_names) = args.labels {
Some(self.resolve_labels_for_mutation(&workspace, &client, label_names).await?)
} else {
None
};
let mut project_id = if let Some(ref project_name) = args.project {
if project_name.eq_ignore_ascii_case("none") {
Some(String::new())
} else {
Some(
client
.get_project_id(project_name)
.await
.map_err(|e| e.to_string())?,
)
}
} else {
None
};
let project_milestone_id = if let Some(ref milestone_name) = args.project_milestone {
if milestone_name.eq_ignore_ascii_case("none") {
Some(String::new())
} else {
if project_id.as_deref() == Some("") {
return Err("Cannot assign a milestone while removing the issue's project.".into());
}
let owning_project_id = project_id
.as_deref()
.filter(|id| !id.is_empty())
.or(issue.project_id.as_deref());
let milestone_id = client
.find_project_milestone(owning_project_id, milestone_name)
.await
.map_err(|e| e.to_string())?;
let milestone = client
.fetch_project_milestone(&milestone_id, &workspace)
.await
.map_err(|e| e.to_string())?;
if project_id.is_none() && issue.project_id.as_deref() != Some(&milestone.project_id) {
project_id = Some(milestone.project_id);
}
Some(milestone_id)
}
} else {
None
};
let assignee_id: Option<String> = if let Some(ref a) = args.assignee {
Some(client.resolve_assignee_id(a).await.map_err(|e| e.to_string())?)
} else {
None
};
// Preserve any image references from the original description
let safe_description = args
.description
.as_ref()
.map(|new_desc| match &issue.description {
Some(original) => preserve_images(original, new_desc),
None => new_desc.clone(),
});
client
.update_issue(
&issue.id,
crate::linear::UpdateIssueInput {
title: args.title.as_deref(),
description: safe_description.as_deref(),
priority: Some(args.priority),
state_id: state_id.as_deref(),
label_ids: label_ids.as_deref(),
project_id: project_id.as_deref(),
assignee_id: assignee_id.as_deref(),
project_milestone_id: project_milestone_id.as_deref(),
},
)
.await
.map_err(|e| e.to_string())?;
if let Some(ref comment_text) = args.comment {
client
.add_comment(&issue.id, comment_text)
.await
.map_err(|e| e.to_string())?;
let _ = client
.sync_issue_comments(&self.db, &issue.id, &workspace)
.await;
}
let (mut updated, updated_relations, updated_label_ids) = client
.fetch_single_issue(&issue.id)
.await
.map_err(|e| e.to_string())?;
updated.workspace_id = workspace.clone();
self.db.upsert_issue(&updated).map_err(|e| e.to_string())?;
self.db
.upsert_relations(&updated.id, &updated_relations)
.map_err(|e| e.to_string())?;
self.db
.replace_issue_labels(&updated.id, &updated_label_ids)
.map_err(|e| e.to_string())?;
// Re-embed if title or description changed
if args.title.is_some() || args.description.is_some() {
self.reembed_issue(&updated).await;
}
let priority_label = match args.priority {
1 => "Urgent",
2 => "High",
3 => "Medium",
4 => "Low",
_ => "Unknown",
};
Ok(serde_json::json!({
"identifier": issue.identifier,
"url": issue.url,
"priority": args.priority,
"priority_label": priority_label,
"title": args.title.as_deref().unwrap_or(&issue.title),
"status": "triaged",
})
.to_string())
}
#[tool(
name = "manage_relation",
description = "Add or remove a relation between two issues. Relation types: 'blocks', 'blocked_by', 'related', 'duplicate'. Use action 'add' to create or 'remove' to delete a relation."
)]
async fn manage_relation(
&self,
#[tool(aggr)] args: ManageRelationArgs,
) -> Result<String, String> {
let workspace = self.require_workspace(&args.workspace)?;
let valid_types = ["blocks", "blocked_by", "related", "duplicate"];
if !valid_types.contains(&args.relation_type.as_str()) {
return Err(format!(
"Invalid relation_type '{}'. Must be one of: {}",
args.relation_type,
valid_types.join(", ")
));
}
let source = self
.db
.get_issue(&args.issue)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("Issue '{}' not found", args.issue))?;
let target = self
.db
.get_issue(&args.related_issue)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("Issue '{}' not found", args.related_issue))?;
let client = self.client_for_workspace(&workspace)?;
match args.action.as_str() {
"add" => {
let relation_id = client
.create_relation(&source.id, &target.id, &args.relation_type)
.await
.map_err(|e| e.to_string())?;
// Re-fetch to update local relations
let (updated, relations, label_ids) = client
.fetch_single_issue(&source.id)
.await
.map_err(|e| e.to_string())?;
self.db.upsert_issue(&updated).map_err(|e| e.to_string())?;
self.db
.upsert_relations(&updated.id, &relations)
.map_err(|e| e.to_string())?;
self.db
.replace_issue_labels(&updated.id, &label_ids)
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"status": "added",
"relation_id": relation_id,
"issue": source.identifier,
"related_issue": target.identifier,
"relation_type": args.relation_type,
})
.to_string())
}
"remove" => {
// For blocked_by, the stored relation is reversed
let (db_source, db_target, db_type) = if args.relation_type == "blocked_by" {
(&target.id, &source.id, "blocks")
} else {
(&source.id, &target.id, args.relation_type.as_str())
};
let relation_id = self
.db
.find_relation_id(db_source, db_target, db_type)
.map_err(|e| e.to_string())?
.ok_or_else(|| {
format!(
"No '{}' relation found between {} and {}",
args.relation_type, args.issue, args.related_issue
)
})?;
client
.delete_relation(&relation_id)
.await
.map_err(|e| e.to_string())?;
// Re-fetch to update local relations
let (updated, relations, label_ids) = client
.fetch_single_issue(&source.id)
.await
.map_err(|e| e.to_string())?;
self.db.upsert_issue(&updated).map_err(|e| e.to_string())?;
self.db
.upsert_relations(&updated.id, &relations)
.map_err(|e| e.to_string())?;
self.db
.replace_issue_labels(&updated.id, &label_ids)
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"status": "removed",
"issue": source.identifier,
"related_issue": target.identifier,
"relation_type": args.relation_type,
})
.to_string())
}
_ => Err("action must be 'add' or 'remove'".into()),
}
}
}
impl RectilinearMcp {
fn require_workspace(&self, workspace: &Option<String>) -> Result<String, String> {
match workspace {
Some(ws) if !ws.is_empty() => {
let names = self.config.workspace_names();
if !names.contains(ws) {
return Err(format!(
"Workspace '{}' not found. Use list_workspaces to see available workspaces. Available: {}",
ws, names.join(", ")
));
}
Ok(ws.clone())
}
_ => Err(
"workspace is required. Use list_workspaces to see available workspaces."
.to_string(),
),
}
}
fn client_for_workspace(&self, workspace: &str) -> Result<LinearClient, String> {
let api_key = self
.config
.workspace_api_key(workspace)
.map_err(|e| e.to_string())?;
Ok(LinearClient::with_api_key(&api_key))
}
async fn resolve_team_ids(
&self,
client: &LinearClient,
values: &[String],
) -> Result<Vec<String>, String> {
if values.is_empty() {
return Err("At least one team is required.".into());
}
let teams = client.list_teams().await.map_err(|error| error.to_string())?;
let mut ids = Vec::new();
for value in values {
let team = teams
.iter()
.find(|team| {
team.id == *value
|| team.key.eq_ignore_ascii_case(value)
|| team.name.eq_ignore_ascii_case(value)
})
.ok_or_else(|| {
format!(
"Team '{}' not found. Available: {}",
value,
teams
.iter()
.map(|team| format!("{} ({})", team.key, team.name))
.collect::<Vec<_>>()
.join(", ")
)
})?;
if !ids.contains(&team.id) {
ids.push(team.id.clone());
}
}
Ok(ids)
}
async fn resolve_member_ids(
&self,
client: &LinearClient,
members: &[String],
) -> Result<Vec<String>, String> {
let mut ids = Vec::new();
for member in members {
if member.eq_ignore_ascii_case("none") {
return Err("Use an empty members list to remove all project members.".into());
}
let id = client
.resolve_assignee_id(member)
.await
.map_err(|error| error.to_string())?;
if !ids.contains(&id) {
ids.push(id);
}
}
Ok(ids)
}
async fn resolve_project_id(
&self,
client: &LinearClient,
workspace: &str,
id_or_name: &str,
) -> Result<String, String> {
if let Some(project) = self
.db
.get_project(workspace, id_or_name)
.map_err(|error| error.to_string())?
{
return Ok(project.id);
}
client
.find_project_by_name(id_or_name)
.await
.map_err(|error| error.to_string())
}
async fn resolve_milestone_id(
&self,
client: &LinearClient,
workspace: &str,
project_id: Option<&str>,
id_or_name: &str,
) -> Result<String, String> {
if let Some(milestone) = self
.db
.get_project_milestone(workspace, id_or_name, project_id)
.map_err(|error| error.to_string())?
{
return Ok(milestone.id);
}
client
.find_project_milestone(project_id, id_or_name)
.await
.map_err(|error| error.to_string())
}
async fn cache_project_and_milestone(
&self,
client: &LinearClient,
workspace: &str,
milestone_id: &str,
) -> Result<(), String> {
let milestone = client
.fetch_project_milestone(milestone_id, workspace)
.await
.map_err(|error| error.to_string())?;
let project = client
.fetch_project(&milestone.project_id, workspace)
.await
.map_err(|error| error.to_string())?;
self.db
.upsert_project(&project)
.map_err(|error| error.to_string())?;
self.db
.upsert_project_milestone(&milestone)
.map_err(|error| error.to_string())?;
Ok(())
}
async fn ensure_comments_synced_if_needed(
&self,
workspace: &str,
issue_id: &str,
) -> Result<(), String> {
let sync_state = self
.db
.get_comment_sync_state(issue_id)
.map_err(|e| e.to_string())?;
if sync_state.status != "not_synced" {
return Ok(());
}
let client = self.client_for_workspace(workspace)?;
let _ = client
.sync_issue_comments(&self.db, issue_id, workspace)
.await;
Ok(())
}
/// Resolve label names to ids using the same logic as create_issue:
/// - empty catalog → defer to remote query (fresh install).
/// - non-empty catalog with unknowns → return user-facing error with did-you-mean.
/// - non-empty catalog, all resolved → return resolved ids.
async fn resolve_labels_for_mutation(
&self,
workspace: &str,
client: &LinearClient,
names: &[String],
) -> Result<Vec<String>, String> {
let catalog_size = self.db
.list_labels(workspace)
.map_err(|e| e.to_string())?
.len();
if catalog_size == 0 {
eprintln!(
"info: labels catalog empty for workspace '{}', resolving via remote query",
workspace
);
return client.get_label_ids(names).await.map_err(|e| e.to_string());
}
let (resolved, unknown) = self.db
.resolve_label_ids_local(workspace, names)
.map_err(|e| e.to_string())?;
if !unknown.is_empty() {
let suggestions = suggest_label_names(&self.db, workspace, &unknown);
return Err(format!(
"Label{} {} not found. {}Run list_labels for the full set.",
if unknown.len() == 1 { "" } else { "s" },
unknown.iter().map(|s| format!("'{}'", s)).collect::<Vec<_>>().join(", "),
if suggestions.is_empty() { String::new() }
else { format!("Did you mean: {}? ", suggestions.join(", ")) }
));
}
Ok(resolved)
}
/// Re-chunk and re-embed a single issue. Best-effort — failures are silently ignored.
async fn reembed_issue(&self, issue: &crate::db::Issue) {
let Ok(embedder) = Embedder::new(&self.config) else {
return;
};
let chunks = embedding::chunk_text(
&issue.title,
issue.description.as_deref().unwrap_or(""),
512,
64,
);
if let Ok(embeddings) = embedder.embed_batch(&chunks).await {
let chunk_data: Vec<(usize, String, Vec<u8>)> = chunks
.into_iter()
.zip(embeddings)
.enumerate()
.map(|(i, (text, emb))| (i, text, embedding::embedding_to_bytes(&emb)))
.collect();
let _ = self.db.upsert_chunks(&issue.id, &chunk_data);
}
}
}
#[tool(tool_box)]
impl ServerHandler for RectilinearMcp {
fn get_info(&self) -> ServerInfo {
ServerInfo {
protocol_version: ProtocolVersion::V_2024_11_05,
capabilities: ServerCapabilities {
tools: Some(ToolsCapability {
list_changed: None,
}),
..Default::default()
},
server_info: Implementation {
name: "rectilinear".into(),
version: env!("CARGO_PKG_VERSION").into(),
},
instructions: Some(
"## Workspace Selection\n\
All tools (except list_workspaces) require a `workspace` parameter. Call list_workspaces first to discover available workspaces.\n\n\
Rectilinear provides Linear issue intelligence plus first-class project and milestone management.\n\n\
## Project Hierarchy\n\
Use list/get/create/update/delete_project and the corresponding project_milestone tools for metadata CRUD. \
Use import_project or import_project_milestone when a downstream client needs one complete portable hierarchy with linked issues. \
update_issue and mark_triaged accept project_milestone in addition to project.\n\n\
## Comment Evidence\n\
When get_issue(include_comments=true) or issue_context returns comments, always inspect comments_status. \
An empty comments array is only evidence that no Linear comments exist when comments_status is `none_found`. \
`not_synced` means comments were not fetched, and `permission_denied` or `unavailable` means Linear could not provide them; \
check comments_sync_error and comments_synced_at. A full sync_team run includes archived issues by default and refreshes comment sync state.\n\n\
## Triage Workflow\n\
IMPORTANT: Present exactly ONE issue at a time. Wait for the user's response and call mark_triaged before presenting the next issue. \
Never batch multiple issues into a single message.\n\n\
When the user asks to triage issues:\n\
1. Call get_triage_queue with the team key. IMPORTANT: Always use get_triage_queue from rectilinear — \
never use Linear's list_issues as a substitute. If the user asks to include completed issues, \
pass include_completed: true.\n\
2. Take the FIRST issue from the queue. BEFORE presenting it to the user, use the code_search_hints field to explore the codebase. \
Search for the mentioned files, symbols, and keywords using Grep, Glob, Read, or Cuttlefish MCP tools (get_symbols, find_references, get_hover_info). \
Spend 2-4 tool calls understanding the current code state for THIS issue.\n\
3. Present a brief summary of the issue AND what you found in the code. \
Assume the perspective of a principal staff software engineer who has been tasked to implement this issue. \
Ask 2-4 thoughtful clarifying questions that would help elucidate any ambiguity or uncertainty in the issue description — \
the kind of questions an experienced engineer asks before writing code \
(e.g. \"I found WorktreeManager.cleanup() at src/worktree.rs:142 — it already handles orphaned worktrees. Is this issue about a gap in that logic, or something else entirely?\"). \
Suggest best-guess answers.\n\
4. WAIT for the user to respond. Based on their answers, propose: priority (1-4), improved title, \
triage comment (use the comment field for adding context, code references, and file paths — prefer comments over description changes to avoid losing images or formatting), \
state change if appropriate (e.g. Done, Cancelled, Duplicate), and any label or project changes. \
Only update the description if the original is genuinely wrong or missing key information.\n\
5. WAIT for user confirmation, then call mark_triaged with all agreed changes.\n\
6. Only after mark_triaged succeeds, move to the NEXT issue. Repeat from step 2. \
When the batch is exhausted, call get_triage_queue again with processed identifiers in exclude.\n\n\
## Archival Mode\n\
To triage completed/canceled issues (for archival prioritization), pass include_completed: true to get_triage_queue. \
This surfaces Done/Canceled/Duplicate issues that have no priority set. The workflow is the same — set a priority for historical record.\n\n\
## Priority Framework\n\
1=Urgent (production down, data loss, security)\n\
2=High (major feature broken, significant user impact, no workaround)\n\
3=Medium (degraded experience, workarounds exist)\n\
4=Low (minor polish, nice-to-have)\n\n\
## Duplicate Handling\n\
If similar_issues show >0.8 similarity, flag as potential duplicate. Ask user whether to merge, close as dup, or keep separate. \
Use the state field in mark_triaged to set the status (e.g. state: \"Duplicate\", state: \"Done\", state: \"Cancelled\").\n\n\
## Labels and Projects\n\
When triaging, consider whether the issue should be labeled or assigned to a project. \
Use the labels, project, and project_milestone fields in mark_triaged to set these. \
Pass project: \"none\" or project_milestone: \"none\" to remove the corresponding relationship.\n\n\
## Issue Relations\n\
Issues may have relations (blocks, blocked_by, related, duplicate) visible in the `relations` field. \
When triaging, surface blocking relationships — they affect priority. Use manage_relation to add/remove relations. \
If an issue blocks or is blocked by another, always mention this prominently.\n\n\
## Linear Links\n\
ALWAYS include the Linear issue URL (from the `url` field) as a clickable markdown link when presenting issues to the user. \
Format as [IDENTIFIER](url) so the user can click through to Linear directly. \
Do this for the main issue being discussed AND for any related, blocking, or similar issues referenced. \
Tool responses include a `referenced_issues` field that maps any issue identifiers found in descriptions/comments \
to their URLs and titles — use these to render all mentioned issues as clickable links.\n\n\
## Progress Tracking\n\
Use TodoWrite to make progress visible to the user:\n\
- When triaging multiple issues from a get_triage_queue batch, create a TodoWrite list with one item per issue \
(e.g. \"Triage ENG-123: <title>\"). Mark each in_progress when you present it, completed after mark_triaged succeeds. \
This gives the user a visible remaining-work indicator across a long batch.\n\
- When a single issue needs multiple clarifying questions or decisions (refinement, priority, tradeoffs, \
related issues, label/project assignment), enumerate them as TodoWrite items before asking. Update the list as you go.\n\
- Skip TodoWrite for trivial one-shot interactions (a single search, a single create_issue with no ambiguity)."
.into(),
),
}
}
}
pub async fn serve(db: Database, config: Config) -> Result<()> {
eprintln!("MCP server ready (stdio transport)");
let handler = RectilinearMcp::new(db, config);
let transport = rmcp::transport::io::stdio();
let server = rmcp::serve_server(handler, transport).await?;
server.waiting().await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_image_references() {
let text = "Some text  more text";
let images = extract_image_references(text);
assert_eq!(
images,
vec"]
);
}
#[test]
fn test_extract_multiple_images() {
let text = " text ";
let images = extract_image_references(text);
assert_eq!(images, vec", ""]);
}
#[test]
fn test_extract_empty_alt() {
let text = "";
let images = extract_image_references(text);
assert_eq!(images, vec"]);
}
#[test]
fn test_no_images() {
let text = "Just some regular text with [a link](url)";
let images = extract_image_references(text);
assert!(images.is_empty());
}
#[test]
fn test_preserve_images_no_originals() {
let result = preserve_images("no images here", "new description");
assert_eq!(result, "new description");
}
#[test]
fn test_preserve_images_keeps_missing() {
let original = "Text  more";
let new_desc = "Rewritten description";
let result = preserve_images(original, new_desc);
assert!(result.starts_with("Rewritten description"));
assert!(result.contains(""));
}
#[test]
fn test_preserve_images_already_present() {
let original = "Text ";
let new_desc = "New text ";
let result = preserve_images(original, new_desc);
assert_eq!(result, "New text ");
}
}