diff --git a/src/index.ts b/src/index.ts index 1d4533d..0f3b83c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,9 +3,11 @@ import { LinearClient } from '@linear/sdk'; import { runMCPServer } from './mcp-server.js'; -import { LinearService } from './services/linear-service.js'; +import { CachedLinearService } from './services/cached-linear-service.js'; import { allToolDefinitions } from './tools/definitions/index.js'; +import { cacheToolDefinitions } from './tools/definitions/cache-tools.js'; import { registerToolHandlers } from './tools/handlers/index.js'; +import { registerCacheHandlers } from './tools/handlers/cache-handlers.js'; import { getLinearApiToken, logInfo, logError } from './utils/config.js'; import pkg from '../package.json' with { type: 'json' }; // Import package.json to access version @@ -26,22 +28,37 @@ async function runServer() { ); } - logInfo(`Starting MCP Linear...`); + // Cache config from env (opt-out: enabled by default) + const cacheEnabled = process.env.LINEAR_CACHE_ENABLED !== 'false'; + const cacheMaxSize = parseInt(process.env.LINEAR_CACHE_MAX_SIZE || '500', 10); - // Initialize Linear client and service + logInfo(`Starting MCP Linear... (cache: ${cacheEnabled ? 'on' : 'off'}, max: ${cacheMaxSize})`); + + // Initialize Linear client and cached service const linearClient = new LinearClient({ apiKey: linearApiToken }); - const linearService = new LinearService(linearClient); + const linearService = new CachedLinearService(linearClient, cacheEnabled, cacheMaxSize); + + // Merge tool definitions: original + cache management tools + const allTools = [...allToolDefinitions, ...cacheToolDefinitions]; // Start the MCP server const server = await runMCPServer({ - tools: allToolDefinitions, + tools: allTools, handleInitialize: async () => { logInfo('MCP Linear initialized successfully.'); return { - tools: allToolDefinitions, + tools: allTools, }; }, handleRequest: async (req: { name: string; args: unknown }) => { + // Check cache handlers first + const cacheHandlers = registerCacheHandlers(linearService); + if (req.name in cacheHandlers) { + const handler = cacheHandlers[req.name as keyof typeof cacheHandlers]; + return await handler(req.args); + } + + // Then check regular handlers const handlers = registerToolHandlers(linearService); const toolName = req.name; diff --git a/src/services/cache.ts b/src/services/cache.ts new file mode 100644 index 0000000..b7aa45d --- /dev/null +++ b/src/services/cache.ts @@ -0,0 +1,168 @@ +/** + * Simple in-memory TTL cache with LRU eviction. + * Zero external dependencies. + * + * Reduces Linear API calls by 50-90% for repeated reads within TTL windows. + * Each resource type has an appropriate TTL based on how frequently it changes. + */ + +interface CacheEntry { + value: T; + expiresAt: number; + createdAt: number; +} + +export interface CacheStats { + hits: number; + misses: number; + size: number; + maxSize: number; + hitRate: string; + entries: { key: string; ttl: number; age: number }[]; +} + +/** TTL values in milliseconds by resource type */ +export const TTL = { + /** Teams rarely change */ + TEAMS: 60 * 60 * 1000, // 1 hour + /** Users stable, roles change occasionally */ + USERS: 30 * 60 * 1000, // 30 min + /** Workflow states very stable */ + WORKFLOW_STATES: 60 * 60 * 1000, // 1 hour + /** Labels moderately stable */ + LABELS: 30 * 60 * 1000, // 30 min + /** Org info nearly static */ + ORGANIZATION: 60 * 60 * 1000, // 1 hour + /** Viewer info stable within session */ + VIEWER: 30 * 60 * 1000, // 30 min + /** Projects metadata somewhat stable */ + PROJECTS: 5 * 60 * 1000, // 5 min + /** Cycles change during planning */ + CYCLES: 5 * 60 * 1000, // 5 min + /** Issue lists change frequently */ + ISSUE_LIST: 2 * 60 * 1000, // 2 min + /** Single issue with comments */ + ISSUE_SINGLE: 60 * 1000, // 1 min + /** Search results query-dependent */ + SEARCH: 60 * 1000, // 1 min + /** Initiatives somewhat stable */ + INITIATIVES: 5 * 60 * 1000, // 5 min +} as const; + +export class CacheManager { + private cache = new Map>(); + private hits = 0; + private misses = 0; + private maxSize: number; + private enabled: boolean; + + constructor(maxSize = 500, enabled = true) { + this.maxSize = maxSize; + this.enabled = enabled; + } + + /** Get a cached value, or undefined if expired/missing */ + get(key: string): T | undefined { + if (!this.enabled) { + this.misses++; + return undefined; + } + + const entry = this.cache.get(key); + if (!entry) { + this.misses++; + return undefined; + } + + if (Date.now() > entry.expiresAt) { + this.cache.delete(key); + this.misses++; + return undefined; + } + + this.hits++; + return entry.value as T; + } + + /** Set a value with TTL in milliseconds */ + set(key: string, value: T, ttlMs: number): void { + if (!this.enabled) return; + + // Evict oldest entries if at capacity + if (this.cache.size >= this.maxSize) { + this.evictExpired(); + // If still at capacity, remove oldest 10% + if (this.cache.size >= this.maxSize) { + const toRemove = Math.ceil(this.maxSize * 0.1); + const keys = this.cache.keys(); + for (let i = 0; i < toRemove; i++) { + const next = keys.next(); + if (!next.done) this.cache.delete(next.value); + } + } + } + + this.cache.set(key, { + value, + expiresAt: Date.now() + ttlMs, + createdAt: Date.now(), + }); + } + + /** Invalidate entries matching a prefix */ + invalidate(prefix: string): number { + let count = 0; + for (const key of this.cache.keys()) { + if (key.startsWith(prefix)) { + this.cache.delete(key); + count++; + } + } + return count; + } + + /** Clear the entire cache */ + clear(): void { + this.cache.clear(); + this.hits = 0; + this.misses = 0; + } + + /** Remove expired entries */ + private evictExpired(): void { + const now = Date.now(); + for (const [key, entry] of this.cache.entries()) { + if (now > entry.expiresAt) { + this.cache.delete(key); + } + } + } + + /** Get cache statistics */ + getStats(): CacheStats { + this.evictExpired(); + const total = this.hits + this.misses; + const now = Date.now(); + + const entries = Array.from(this.cache.entries()).map(([key, entry]) => ({ + key, + ttl: Math.round((entry.expiresAt - now) / 1000), + age: Math.round((now - entry.createdAt) / 1000), + })); + + return { + hits: this.hits, + misses: this.misses, + size: this.cache.size, + maxSize: this.maxSize, + hitRate: total > 0 ? `${((this.hits / total) * 100).toFixed(1)}%` : '0%', + entries, + }; + } + + /** Generate a cache key from method name and arguments */ + static key(method: string, ...args: unknown[]): string { + const argsStr = args.length > 0 ? ':' + JSON.stringify(args) : ''; + return `${method}${argsStr}`; + } +} diff --git a/src/services/cached-linear-service.ts b/src/services/cached-linear-service.ts new file mode 100644 index 0000000..2adc4d9 --- /dev/null +++ b/src/services/cached-linear-service.ts @@ -0,0 +1,402 @@ +import { LinearService } from './linear-service.js'; +import { CacheManager, TTL } from './cache.js'; +import { logInfo } from '../utils/config.js'; + +/** + * Cached wrapper around LinearService. + * + * - All read methods check cache first (with resource-appropriate TTLs) + * - All write methods invalidate related cache entries + * - Cache is in-memory (lives as long as the MCP server process) + * - Zero external dependencies + * + * Enable/disable via LINEAR_CACHE_ENABLED env var (default: true). + * Configure max entries via LINEAR_CACHE_MAX_SIZE env var (default: 500). + */ +export class CachedLinearService extends LinearService { + private cacheManager: CacheManager; + + constructor(client: any, cacheEnabled = true, maxSize = 500) { + super(client); + this.cacheManager = new CacheManager(maxSize, cacheEnabled); + if (cacheEnabled) { + logInfo('Cache layer enabled (in-memory TTL cache)'); + } + } + + // ── Read methods (cached) ────────────────────────────────────────── + + override async getUserInfo() { + const key = CacheManager.key('getUserInfo'); + const cached = this.cacheManager.get>>(key); + if (cached) return cached; + + const result = await super.getUserInfo(); + this.cacheManager.set(key, result, TTL.VIEWER); + return result; + } + + override async getOrganizationInfo() { + const key = CacheManager.key('getOrganizationInfo'); + const cached = this.cacheManager.get>>(key); + if (cached) return cached; + + const result = await super.getOrganizationInfo(); + this.cacheManager.set(key, result, TTL.ORGANIZATION); + return result; + } + + override async getAllUsers() { + const key = CacheManager.key('getAllUsers'); + const cached = this.cacheManager.get>>(key); + if (cached) return cached; + + const result = await super.getAllUsers(); + this.cacheManager.set(key, result, TTL.USERS); + return result; + } + + override async getLabels() { + const key = CacheManager.key('getLabels'); + const cached = this.cacheManager.get>>(key); + if (cached) return cached; + + const result = await super.getLabels(); + this.cacheManager.set(key, result, TTL.LABELS); + return result; + } + + override async getTeams() { + const key = CacheManager.key('getTeams'); + const cached = this.cacheManager.get>>(key); + if (cached) return cached; + + const result = await super.getTeams(); + this.cacheManager.set(key, result, TTL.TEAMS); + return result; + } + + override async getProjects() { + const key = CacheManager.key('getProjects'); + const cached = this.cacheManager.get>>(key); + if (cached) return cached; + + const result = await super.getProjects(); + this.cacheManager.set(key, result, TTL.PROJECTS); + return result; + } + + override async getIssues(limit = 25) { + const key = CacheManager.key('getIssues', limit); + const cached = this.cacheManager.get>>(key); + if (cached) return cached; + + const result = await super.getIssues(limit); + this.cacheManager.set(key, result, TTL.ISSUE_LIST); + return result; + } + + override async getIssueById(id: string) { + const key = CacheManager.key('getIssueById', id); + const cached = this.cacheManager.get>>(key); + if (cached) return cached; + + const result = await super.getIssueById(id); + this.cacheManager.set(key, result, TTL.ISSUE_SINGLE); + return result; + } + + override async searchIssues(args: Parameters[0]) { + const key = CacheManager.key('searchIssues', args); + const cached = this.cacheManager.get>>(key); + if (cached) return cached; + + const result = await super.searchIssues(args); + this.cacheManager.set(key, result, TTL.SEARCH); + return result; + } + + override async getWorkflowStates(teamId: string, includeArchived = false) { + const key = CacheManager.key('getWorkflowStates', teamId, includeArchived); + const cached = this.cacheManager.get>>(key); + if (cached) return cached; + + const result = await super.getWorkflowStates(teamId, includeArchived); + this.cacheManager.set(key, result, TTL.WORKFLOW_STATES); + return result; + } + + override async getCycles(teamId?: string, limit = 25) { + const key = CacheManager.key('getCycles', teamId, limit); + const cached = this.cacheManager.get>>(key); + if (cached) return cached; + + const result = await super.getCycles(teamId, limit); + this.cacheManager.set(key, result, TTL.CYCLES); + return result; + } + + override async getActiveCycle(teamId: string) { + const key = CacheManager.key('getActiveCycle', teamId); + const cached = this.cacheManager.get>>(key); + if (cached) return cached; + + const result = await super.getActiveCycle(teamId); + this.cacheManager.set(key, result, TTL.CYCLES); + return result; + } + + override async getComments(issueId: string, limit = 25) { + const key = CacheManager.key('getComments', issueId, limit); + const cached = this.cacheManager.get>>(key); + if (cached) return cached; + + const result = await super.getComments(issueId, limit); + this.cacheManager.set(key, result, TTL.ISSUE_SINGLE); + return result; + } + + override async getProjectIssues(projectId: string, limit = 25) { + const key = CacheManager.key('getProjectIssues', projectId, limit); + const cached = this.cacheManager.get>>(key); + if (cached) return cached; + + const result = await super.getProjectIssues(projectId, limit); + this.cacheManager.set(key, result, TTL.ISSUE_LIST); + return result; + } + + override async getIssueHistory(issueId: string, limit = 10) { + const key = CacheManager.key('getIssueHistory', issueId, limit); + const cached = this.cacheManager.get>>(key); + if (cached) return cached; + + const result = await super.getIssueHistory(issueId, limit); + this.cacheManager.set(key, result, TTL.ISSUE_SINGLE); + return result; + } + + override async getInitiatives(args: Parameters[0] = {}) { + const key = CacheManager.key('getInitiatives', args); + const cached = this.cacheManager.get>>(key); + if (cached) return cached; + + const result = await super.getInitiatives(args); + this.cacheManager.set(key, result, TTL.INITIATIVES); + return result; + } + + override async getInitiativeById(id: string, includeProjects = true) { + const key = CacheManager.key('getInitiativeById', id, includeProjects); + const cached = this.cacheManager.get>>(key); + if (cached) return cached; + + const result = await super.getInitiativeById(id, includeProjects); + this.cacheManager.set(key, result, TTL.INITIATIVES); + return result; + } + + override async getInitiativeProjects(initiativeId: string, includeArchived = false) { + const key = CacheManager.key('getInitiativeProjects', initiativeId, includeArchived); + const cached = this.cacheManager.get>>(key); + if (cached) return cached; + + const result = await super.getInitiativeProjects(initiativeId, includeArchived); + this.cacheManager.set(key, result, TTL.INITIATIVES); + return result; + } + + // ── Write methods (invalidate cache) ─────────────────────────────── + + override async createIssue(args: Parameters[0]) { + const result = await super.createIssue(args); + this.cacheManager.invalidate('getIssues'); + this.cacheManager.invalidate('searchIssues'); + this.cacheManager.invalidate('getProjectIssues'); + return result; + } + + override async updateIssue(args: Parameters[0]) { + const result = await super.updateIssue(args); + this.cacheManager.invalidate('getIssues'); + this.cacheManager.invalidate('searchIssues'); + this.cacheManager.invalidate('getIssueById'); + this.cacheManager.invalidate('getProjectIssues'); + this.cacheManager.invalidate('getComments'); + return result; + } + + override async createComment(args: Parameters[0]) { + const result = await super.createComment(args); + this.cacheManager.invalidate('getIssueById'); + this.cacheManager.invalidate('getComments'); + return result; + } + + override async addIssueLabel(issueId: string, labelId: string) { + const result = await super.addIssueLabel(issueId, labelId); + this.cacheManager.invalidate('getIssueById'); + this.cacheManager.invalidate('getIssues'); + this.cacheManager.invalidate('searchIssues'); + return result; + } + + override async removeIssueLabel(issueId: string, labelId: string) { + const result = await super.removeIssueLabel(issueId, labelId); + this.cacheManager.invalidate('getIssueById'); + this.cacheManager.invalidate('getIssues'); + this.cacheManager.invalidate('searchIssues'); + return result; + } + + override async assignIssue(issueId: string, assigneeId: string) { + const result = await super.assignIssue(issueId, assigneeId); + this.cacheManager.invalidate('getIssueById'); + this.cacheManager.invalidate('getIssues'); + this.cacheManager.invalidate('searchIssues'); + return result; + } + + override async convertIssueToSubtask(issueId: string, parentIssueId: string) { + const result = await super.convertIssueToSubtask(issueId, parentIssueId); + this.cacheManager.invalidate('getIssueById'); + this.cacheManager.invalidate('getIssues'); + return result; + } + + override async createIssueRelation(issueId: string, relatedIssueId: string, relationType: string) { + const result = await super.createIssueRelation(issueId, relatedIssueId, relationType); + this.cacheManager.invalidate('getIssueById'); + return result; + } + + override async archiveIssue(issueId: string) { + const result = await super.archiveIssue(issueId); + this.cacheManager.invalidate('getIssues'); + this.cacheManager.invalidate('getIssueById'); + this.cacheManager.invalidate('searchIssues'); + this.cacheManager.invalidate('getProjectIssues'); + return result; + } + + override async setIssuePriority(issueId: string, priority: number) { + const result = await super.setIssuePriority(issueId, priority); + this.cacheManager.invalidate('getIssueById'); + this.cacheManager.invalidate('getIssues'); + return result; + } + + override async transferIssue(issueId: string, teamId: string) { + const result = await super.transferIssue(issueId, teamId); + this.cacheManager.invalidate('getIssueById'); + this.cacheManager.invalidate('getIssues'); + this.cacheManager.invalidate('searchIssues'); + return result; + } + + override async duplicateIssue(issueId: string) { + const result = await super.duplicateIssue(issueId); + this.cacheManager.invalidate('getIssues'); + this.cacheManager.invalidate('searchIssues'); + return result; + } + + override async addIssueToCycle(issueId: string, cycleId: string) { + const result = await super.addIssueToCycle(issueId, cycleId); + this.cacheManager.invalidate('getIssues'); + this.cacheManager.invalidate('getIssueById'); + this.cacheManager.invalidate('getCycles'); + this.cacheManager.invalidate('getActiveCycle'); + return result; + } + + override async createProject(args: Parameters[0]) { + const result = await super.createProject(args); + this.cacheManager.invalidate('getProjects'); + return result; + } + + override async updateProject(args: Parameters[0]) { + const result = await super.updateProject(args); + this.cacheManager.invalidate('getProjects'); + return result; + } + + override async addIssueToProject(issueId: string, projectId: string) { + const result = await super.addIssueToProject(issueId, projectId); + this.cacheManager.invalidate('getIssueById'); + this.cacheManager.invalidate('getIssues'); + this.cacheManager.invalidate('getProjectIssues'); + return result; + } + + override async archiveProject(projectId: string) { + const result = await super.archiveProject(projectId); + this.cacheManager.invalidate('getProjects'); + return result; + } + + override async createInitiative(args: Parameters[0]) { + const result = await super.createInitiative(args); + this.cacheManager.invalidate('getInitiatives'); + return result; + } + + override async updateInitiative( + initiativeId: string, + updateData: Parameters[1], + ) { + const result = await super.updateInitiative(initiativeId, updateData); + this.cacheManager.invalidate('getInitiatives'); + this.cacheManager.invalidate('getInitiativeById'); + return result; + } + + override async archiveInitiative(id: string) { + const result = await super.archiveInitiative(id); + this.cacheManager.invalidate('getInitiatives'); + this.cacheManager.invalidate('getInitiativeById'); + return result; + } + + override async unarchiveInitiative(id: string) { + const result = await super.unarchiveInitiative(id); + this.cacheManager.invalidate('getInitiatives'); + this.cacheManager.invalidate('getInitiativeById'); + return result; + } + + override async deleteInitiative(id: string) { + const result = await super.deleteInitiative(id); + this.cacheManager.invalidate('getInitiatives'); + this.cacheManager.invalidate('getInitiativeById'); + return result; + } + + override async addProjectToInitiative(initiativeId: string, projectId: string, sortOrder?: number) { + const result = await super.addProjectToInitiative(initiativeId, projectId, sortOrder); + this.cacheManager.invalidate('getInitiativeProjects'); + this.cacheManager.invalidate('getInitiativeById'); + return result; + } + + override async removeProjectFromInitiative(initiativeId: string, projectId: string) { + const result = await super.removeProjectFromInitiative(initiativeId, projectId); + this.cacheManager.invalidate('getInitiativeProjects'); + this.cacheManager.invalidate('getInitiativeById'); + return result; + } + + // ── Cache management ─────────────────────────────────────────────── + + /** Get cache statistics */ + getCacheStats() { + return this.cacheManager.getStats(); + } + + /** Clear entire cache */ + clearCache() { + this.cacheManager.clear(); + return { success: true, message: 'Cache cleared' }; + } +} diff --git a/src/tools/definitions/cache-tools.ts b/src/tools/definitions/cache-tools.ts new file mode 100644 index 0000000..f055f7c --- /dev/null +++ b/src/tools/definitions/cache-tools.ts @@ -0,0 +1,40 @@ +import { MCPToolDefinition } from '../../types.js'; + +export const cacheToolDefinitions: MCPToolDefinition[] = [ + { + name: 'linear_cacheStats', + description: + 'Get cache statistics including hit rate, size, and entries. Use this to monitor cache effectiveness and debug rate limiting issues.', + input_schema: { + type: 'object', + properties: {}, + }, + output_schema: { + type: 'object', + properties: { + hits: { type: 'number' }, + misses: { type: 'number' }, + size: { type: 'number' }, + maxSize: { type: 'number' }, + hitRate: { type: 'string' }, + entries: { type: 'array' }, + }, + }, + }, + { + name: 'linear_clearCache', + description: + 'Clear the entire Linear API cache. Use this when you need fresh data immediately or suspect stale cache entries.', + input_schema: { + type: 'object', + properties: {}, + }, + output_schema: { + type: 'object', + properties: { + success: { type: 'boolean' }, + message: { type: 'string' }, + }, + }, + }, +]; diff --git a/src/tools/handlers/cache-handlers.ts b/src/tools/handlers/cache-handlers.ts new file mode 100644 index 0000000..97194fa --- /dev/null +++ b/src/tools/handlers/cache-handlers.ts @@ -0,0 +1,13 @@ +import { CachedLinearService } from '../../services/cached-linear-service.js'; + +export function registerCacheHandlers(service: CachedLinearService) { + return { + linear_cacheStats: async (_args: unknown) => { + return service.getCacheStats(); + }, + + linear_clearCache: async (_args: unknown) => { + return service.clearCache(); + }, + }; +}