Linear MCP Server
by Iwark
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { LinearClient, LinearError } from '@linear/sdk';
import { z } from "zod";
import dotenv from 'dotenv';
dotenv.config();
if (!process.env.LINEAR_API_KEY) {
console.error('ERROR: LINEAR_API_KEY is not set in .env file');
process.exit(1);
}
class RateLimiter {
constructor() {
this.requestsPerHour = 1000;
this.requests = [];
this.metrics = {
totalRequests: 0,
requestsInLastHour: 0,
averageRequestTime: 0,
queueLength: 0,
lastRequestTime: Date.now()
};
}
async checkLimit() {
const now = Date.now();
this.requests = this.requests.filter(time => now - time < 3600000);
if (this.requests.length >= this.requestsPerHour) {
throw new Error('Rate limit exceeded');
}
this.requests.push(now);
// Update metrics
this.metrics.totalRequests++;
this.metrics.requestsInLastHour = this.requests.length;
this.metrics.lastRequestTime = now;
}
getMetrics() {
return {
...this.metrics,
remainingRequests: this.requestsPerHour - this.metrics.requestsInLastHour
};
}
}
// Initialize Linear client with custom headers
const linearClient = new LinearClient({
apiKey: process.env.LINEAR_API_KEY,
headers: {
'User-Agent': 'Linear MCP Server/1.0.0'
}
});
const rateLimiter = new RateLimiter();
const PRIORITY_LABELS = ['No priority', 'Urgent', 'High', 'Medium', 'Low'];
// Issue mapping helper
const mapIssue = (issue) => ({
id: issue.id,
identifier: issue.identifier,
title: issue.title,
status: issue.state?.name,
assignee: issue.assignee?.name,
priority: PRIORITY_LABELS[issue.priority || 0],
url: issue.url,
createdAt: issue.createdAt,
estimate: issue.estimate,
labels: issue.labels?.nodes?.map(label => label.name) || [],
description: issue.description
});
// Default prompt definition
const defaultPrompt = {
name: "default",
description: "Default prompt for Linear MCP Server",
messages: [
{
role: "system",
content: {
type: "text",
text: "You are a Linear assistant that helps manage issues and projects. For issue queries, use the search-issues tool directly with appropriate filters like 'assignee:@me' and 'priority:high'."
}
}
]
};
// Initialize MCP server
const server = new McpServer({
name: "linear",
version: "1.0.0",
description: "Linear MCP Server for accessing Linear resources",
capabilities: {
prompts: {
default: defaultPrompt
},
resources: {
templates: true,
read: true
},
tools: {
"create-issue": {
description: "Create a new Linear issue"
},
"search-issues": {
description: "Search Linear issues"
},
"read-resource": {
description: "Read a Linear resource"
}
}
}
});
// Error handling helper
const handleLinearError = (error) => {
if (error instanceof LinearError) {
return `Linear API Error: ${error.message}`;
}
return `Error: ${error.message}`;
};
// Tool to create an issue
server.tool(
"create-issue",
"Create a new Linear issue",
{
title: z.string().describe("Issue title"),
teamId: z.string().describe("Team ID"),
description: z.string().optional().describe("Issue description"),
priority: z.number().min(0).max(4).optional().describe(`Issue priority (${PRIORITY_LABELS.map((label, index) => `${index}: ${label}`).join(', ')})`),
stateId: z.string().optional().describe("State ID"),
assigneeId: z.string().optional().describe("Assignee ID"),
estimate: z.number().optional().describe("Issue estimate"),
labelIds: z.array(z.string()).optional().describe("Label IDs")
},
async (input) => {
try {
const issue = await linearClient.issueCreate(input);
if (!issue.success) {
throw new Error("Failed to create issue");
}
return createResponse({
success: true,
issue: mapIssue(issue.issue)
});
} catch (error) {
console.error("Error in create-issue:", error);
return createResponse({ error: handleLinearError(error) });
}
}
);
// Tool to search issues
server.tool(
"search-issues",
"Search Linear issues",
{
query: z.string().describe("Search query"),
teamId: z.string().optional().describe("Team ID to filter by"),
status: z.string().optional().describe("Status to filter by"),
assigneeId: z.string().optional().describe("Assignee ID to filter by")
},
async ({ query }) => {
try {
await rateLimiter.checkLimit();
let me;
try {
me = await linearClient.viewer;
} catch (error) {
console.error("Failed to get viewer:", error);
throw new Error("Failed to get current user information");
}
const { filter, isMyIssuesQuery, priorityFilter } = parseQuery(query);
if (isMyIssuesQuery) {
const myIssues = await me.assignedIssues({
first: 100,
orderBy: "updatedAt",
filter
});
const mappedIssues = myIssues.nodes.map(mapIssue);
console.error('Debug - Raw issue data:', JSON.stringify(myIssues.nodes[0], null, 2));
console.error('Debug - Mapped issue:', JSON.stringify(mappedIssues[0], null, 2));
return createResponse({
message: `Found ${mappedIssues.length} issues assigned to you`,
total: mappedIssues.length,
issues: mappedIssues
});
}
const { nodes } = await linearClient.issues({
first: 100,
filter: {
...filter,
priority: priorityFilter
},
orderBy: "updatedAt",
includeArchived: false
});
const mappedIssues = nodes.map(mapIssue);
console.error('Debug - Raw issue data:', JSON.stringify(nodes[0], null, 2));
console.error('Debug - Mapped issue:', JSON.stringify(mappedIssues[0], null, 2));
return createResponse({
message: `Found ${mappedIssues.length} issues`,
total: mappedIssues.length,
issues: mappedIssues
});
} catch (error) {
console.error("Error in search-issues:", error);
return createResponse({ error: handleLinearError(error) });
}
}
);
// Tool to read a resource
server.tool(
"read-resource",
"Read a Linear resource",
{
uri: z.string().describe("Resource URI to read e.g. linear://issues/4cb972e7-9ba1-4c52-8465-cdf2679ccea7")
},
async ({ uri }) => {
try {
let data;
const matches = uri.match(/^linear:\/\/([^/]+)\/(.+)$/);
if (!matches) {
throw new Error(`Invalid Linear URI format: ${uri}`);
}
const [, resourceType, id] = matches;
switch (resourceType) {
case "issues": {
if (id) {
const issue = await linearClient.issue(id);
if (!issue) {
throw new Error(`Issue not found: ${id}`);
}
data = mapIssue(issue);
} else {
const { nodes } = await linearClient.issues({
first: 100,
orderBy: "updatedAt",
includeArchived: false
});
data = nodes.map(mapIssue);
}
break;
}
case "organization": {
const org = await linearClient.organization;
data = {
id: org.id,
name: org.name,
urlKey: org.urlKey,
createdAt: org.createdAt
};
break;
}
case "teams": {
if (id) {
const team = await linearClient.team(id);
const states = await team.states().then(states =>
states.nodes.map(state => ({
id: state.id,
name: state.name,
type: state.type
}))
);
data = mapTeam(team, states);
} else {
const { nodes } = await linearClient.teams();
data = await Promise.all(nodes.map(async team => {
const states = await team.states().then(states =>
states.nodes.map(state => ({
id: state.id,
name: state.name,
type: state.type
}))
);
return mapTeam(team, states);
}));
}
break;
}
default:
throw new Error(`Unknown resource type: ${resourceType}`);
}
return createResponse({ data });
} catch (error) {
console.error("Error in read-resource:", error);
return createResponse({ error: handleLinearError(error) });
}
}
);
// Start the server
async function main() {
try {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Linear MCP Server running on stdio");
} catch (error) {
console.error("Error starting server:", error);
process.exit(1);
}
}
main().catch((error) => {
console.error("Fatal error in main():", error);
process.exit(1);
});
const parseQuery = (query) => {
const queryParts = query.match(/\S+:"[^"]+"|[^:\s]+:[^\s]+|\S+/g) || [];
const filter = {
state: {
type: {
nin: ["completed", "canceled"]
}
}
};
let isMyIssuesQuery = false;
let priorityFilter;
let hasExplicitStateFilter = false;
for (const part of queryParts) {
const [key, value] = part.split(':');
const cleanValue = value?.replace(/^"(.*)"$/, '$1');
const result = parseQueryPart(key, cleanValue, filter);
isMyIssuesQuery = result.isMyIssuesQuery || isMyIssuesQuery;
priorityFilter = result.priorityFilter || priorityFilter;
hasExplicitStateFilter = result.hasExplicitStateFilter || hasExplicitStateFilter;
}
// Remove default status filter if status is explicitly specified
if (hasExplicitStateFilter) {
delete filter.state.type;
}
return { filter, isMyIssuesQuery, priorityFilter };
};
const parseQueryPart = (key, cleanValue, filter) => {
let isMyIssuesQuery = false;
let priorityFilter;
let hasExplicitStateFilter = false;
switch (key) {
case 'assignee':
if (cleanValue === '@me') {
isMyIssuesQuery = true;
}
break;
case 'priority':
priorityFilter = parsePriorityFilter(cleanValue);
break;
case 'state':
case 'status':
hasExplicitStateFilter = true;
filter.state = { name: { eq: cleanValue } };
break;
case 'team':
filter.team = { name: { eq: cleanValue } };
break;
case 'label':
filter.labels = { name: { eq: cleanValue } };
break;
default:
if (!key.includes(':')) {
filter.or = [
{ title: { contains: key } },
{ description: { contains: key } }
];
}
}
return { isMyIssuesQuery, priorityFilter, hasExplicitStateFilter };
};
const parsePriorityFilter = (value) => {
const PRIORITY_MAP = {
'no': 0,
'urgent': 1,
'high': 2,
'medium': 3,
'low': 4
};
// If the value is a number
if (!isNaN(value)) {
const numValue = parseInt(value, 10);
if (numValue >= 0 && numValue <= 4) {
if (numValue === 2) {
return { in: [1, 2] }; // Include both Urgent (1) and High (2)
}
return { eq: numValue };
}
return undefined;
}
// Otherwise, use the priority map
const lowercaseValue = value.toLowerCase();
if (lowercaseValue === 'high') {
return { in: [1, 2] }; // Include both Urgent (1) and High (2)
} else if (PRIORITY_MAP[lowercaseValue] !== undefined) {
return { eq: PRIORITY_MAP[lowercaseValue] };
}
return undefined;
};
const mapTeam = (team, states) => ({
id: team.id,
name: team.name,
key: team.key,
description: team.description,
states: states.map(mapState)
});
const mapState = (state) => ({
id: state.id,
name: state.name,
type: state.type
});
const createResponse = (data) => ({
content: [
{
type: "text",
text: JSON.stringify({
...data,
apiMetrics: rateLimiter.getMetrics()
}, null, 2)
}
]
});