src/mcp.js (18999 bytes)
1 // Minimal MCP server over stdio (newline-delimited JSON-RPC 2.0). No dependencies, so the plugin 2 // runs straight from a git checkout with nothing to install. 3 4 import fs from 'node:fs'; 5 import path from 'node:path'; 6 import readline from 'node:readline'; 7 import { fileURLToPath } from 'node:url'; 8 import * as archive from './archive.js'; 9 import { headlessTriage, triageFirst } from './claude.js'; 10 import * as embed from './embed.js'; 11 import { knownProjects } from './projects.js'; 12 import { renderAdded, renderBoard, renderFound, renderGroups, renderIdea } from './render.js'; 13 import { BATCH_SCHEMA, MODELS, pendingIdeas, triagePrompt } from './rubric.js'; 14 import { counts, FILTERS, findIdea, lane, listIdeas, load, pickNext, STATUSES } from './store.js'; 15 import { clip, splitIdeas } from './text.js'; 16 17 const ROOT = new URL('..', import.meta.url); 18 const VERSION = JSON.parse(fs.readFileSync(new URL('package.json', ROOT), 'utf8')).version; 19 const PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05']; 20 21 const INSTRUCTIONS = 22 'ideamine is the user\'s idea archive, shared by every session, project, and model. When the user tosses out an ' + 23 'idea for later ("idea: ...", "save this idea", "someday we should ..."), save it with idea_add and continue the ' + 24 'current task. Do not start building a saved idea unless the user asks.'; 25 26 const currentProject = () => process.env.CLAUDE_PROJECT_DIR || process.cwd(); 27 28 const MODEL_ENUM = MODELS.map((m) => m.alias); 29 30 const TOOLS = [ 31 { 32 name: 'idea_add', 33 description: 34 'Save an idea to the ideamine archive. Use when the user shares an idea to keep for later. Only save it: ' + 35 'do not plan or build it, and continue what you were doing. A bulleted list becomes one idea per bullet.', 36 inputSchema: { 37 type: 'object', 38 properties: { 39 text: { type: 'string', description: 'The idea, verbatim.' }, 40 tags: { type: 'array', items: { type: 'string' } }, 41 project: { type: 'string', description: 'Project directory the idea belongs to. Default: current project.' }, 42 }, 43 required: ['text'], 44 additionalProperties: false, 45 }, 46 annotations: { title: 'Save idea', readOnlyHint: false, destructiveHint: false, openWorldHint: false }, 47 }, 48 { 49 name: 'idea_list', 50 description: 51 'Show the idea board, or one idea in full when id is given. full=true shows every listed idea in full. ' + 52 'semantic=true ranks ideas by meaning for the query. groups=true groups the listed ideas by meaning.', 53 inputSchema: { 54 type: 'object', 55 properties: { 56 id: { type: 'integer', description: 'Show this idea in full.' }, 57 filter: { type: 'string', enum: FILTERS, description: 'Default: open (doing, do, maybe, inbox).' }, 58 query: { type: 'string', description: 'Only ideas containing all of these words (with semantic: ideas like it).' }, 59 semantic: { type: 'boolean', description: 'Rank by meaning (nomic-embed-text), best first. Falls back to words.' }, 60 groups: { type: 'boolean', description: 'Group the listed ideas by meaning.' }, 61 here: { type: 'boolean', description: 'Only ideas from the current project.' }, 62 limit: { type: 'integer' }, 63 full: { type: 'boolean', description: 'Every listed idea in full (brief, text, notes), not one line each.' }, 64 }, 65 additionalProperties: false, 66 }, 67 annotations: { title: 'List ideas', readOnlyHint: true, openWorldHint: false }, 68 }, 69 { 70 name: 'idea_triage', 71 description: 72 'Triage ideas: decide which are worth doing and the cheapest model that can build each. headless=true ' + 73 'does it in a separate minimal Claude Code call (cheapest; your context is not used). Otherwise call with ' + 74 'no verdicts to get the rubric and the untriaged ideas, then call once more with all verdicts.', 75 inputSchema: { 76 type: 'object', 77 properties: { 78 headless: { type: 'boolean', description: 'Triage the inbox in a separate low-cost `claude -p` call.' }, 79 model: { type: 'string', enum: MODEL_ENUM, description: 'Model for headless triage (default sonnet).' }, 80 verdicts: BATCH_SCHEMA.properties.verdicts, 81 ids: { type: 'array', items: { type: 'integer' }, description: 'Re-triage these ideas instead of the inbox.' }, 82 limit: { type: 'integer', description: 'Max ideas (default 20 headless, 30 otherwise).' }, 83 by: { type: 'string', description: 'Your model name, recorded with the verdicts.' }, 84 }, 85 additionalProperties: false, 86 }, 87 annotations: { title: 'Triage ideas', readOnlyHint: false, destructiveHint: false, openWorldHint: false }, 88 }, 89 { 90 name: 'idea_update', 91 description: 'Change an idea: status, text, title, tags, model override, project, or append a note.', 92 inputSchema: { 93 type: 'object', 94 properties: { 95 id: { type: 'integer' }, 96 status: { type: 'string', enum: [...STATUSES, 'reopen'] }, 97 note: { type: 'string', description: 'Appended to the idea\'s notes, e.g. the outcome.' }, 98 title: { type: 'string' }, 99 text: { type: 'string' }, 100 tags: { type: 'array', items: { type: 'string' } }, 101 model: { type: 'string', enum: MODEL_ENUM }, 102 project: { type: 'string' }, 103 }, 104 required: ['id'], 105 additionalProperties: false, 106 }, 107 annotations: { title: 'Update idea', readOnlyHint: false, destructiveHint: false, openWorldHint: false }, 108 }, 109 { 110 name: 'idea_next', 111 description: 112 'Pick the idea to build next (verdict "do" before "maybe", current project first, then best value per ' + 113 'effort), or fetch one by id. Returns its brief, project, and the model recommended to build it. Without ' + 114 'id, it also returns the whole queue. triage=true first triages the untriaged candidates (the inbox, or ' + 115 'the given id) in one headless call.', 116 inputSchema: { 117 type: 'object', 118 properties: { 119 id: { type: 'integer' }, 120 here: { type: 'boolean', description: 'Only consider ideas from the current project.' }, 121 triage: { type: 'boolean', description: 'Triage untriaged candidates first, so the pick and the model are current.' }, 122 }, 123 additionalProperties: false, 124 }, 125 annotations: { title: 'Next idea', readOnlyHint: false, destructiveHint: false, openWorldHint: false }, 126 }, 127 { 128 name: 'idea_remove', 129 description: 130 'Take ideas out of the queue: delete them from the archive for good, and return each one in full. ' + 131 'An unknown id is an error, and then nothing is deleted.', 132 inputSchema: { 133 type: 'object', 134 properties: { 135 ids: { type: 'array', items: { type: 'integer' }, description: 'The ideas to delete.' }, 136 }, 137 required: ['ids'], 138 additionalProperties: false, 139 }, 140 annotations: { title: 'Remove ideas', readOnlyHint: false, destructiveHint: true, openWorldHint: false }, 141 }, 142 ]; 143 144 // Slash commands for clients that load this server without the plugin (the plugin ships skills instead). 145 // The prompt text is the matching SKILL.md body, so both stay in sync. 146 const PROMPTS = [ 147 { name: 'idea', description: 'Save an idea for later', arguments: [{ name: 'text', required: true }] }, 148 { name: 'ideas', description: 'Show the queue, or ask about your ideas', arguments: [{ name: 'question', required: false }] }, 149 { name: 'ideas-ls', description: 'List the queue, one lane, or every lane (-a)', arguments: [{ name: 'lane', required: false }] }, 150 { name: 'ideas-cat', description: 'Show ideas in full', arguments: [{ name: 'ids', required: true }] }, 151 { name: 'ideas-rm', description: 'Delete ideas for good', arguments: [{ name: 'ids', required: true }] }, 152 { name: 'ideas-find', description: 'Search ideas by meaning', arguments: [{ name: 'words', required: true }] }, 153 { name: 'ideas-groups', description: 'Show ideas grouped by meaning', arguments: [{ name: 'lane', required: false }] }, 154 { name: 'ideas-done', description: 'Mark an idea done', arguments: [{ name: 'id', required: true }, { name: 'note', required: false }] }, 155 { name: 'ideas-reopen', description: 'Put an idea back in the queue', arguments: [{ name: 'id', required: true }] }, 156 { name: 'ideas-go', description: 'Build the next idea on its recommended model', arguments: [{ name: 'id', required: false }] }, 157 { name: 'ideas-all', description: 'Do every idea that fits this chat', arguments: [] }, 158 { name: 'ideas-sort', description: 'Triage the inbox now and show the queue', arguments: [] }, 159 { name: 'ideas-watch', description: 'Turn the background watcher on or off', arguments: [{ name: 'off', required: false }] }, 160 { name: 'ideas-web', description: 'Start or stop the dashboard with buttons on this PC', arguments: [{ name: 'off', required: false }] }, 161 { name: 'ideas-sync', description: 'Share one archive between your machines through an ideamine server', arguments: [{ name: 'url', required: false }] }, 162 ]; 163 164 function skillBody(name, args) { 165 const file = new URL(`skills/${name}/SKILL.md`, ROOT); 166 const text = fs.readFileSync(fileURLToPath(file), 'utf8').replace(/^---[\s\S]*?\n---\s*\n/, ''); 167 return text.replaceAll('$ARGUMENTS', args || '').trim(); 168 } 169 170 function summarizeTriage(results, how = '') { 171 const ok = results.filter((r) => !r.error); 172 const tally = ['do', 'maybe', 'skip'].map((v) => `${ok.filter((r) => r.verdict === v).length} ${v}`).join(' · '); 173 const lines = [`Saved ${ok.length} verdict${ok.length === 1 ? '' : 's'}: ${tally}${how ? ` (${how})` : ''}`]; 174 for (const r of ok) { 175 const meta = r.verdict === 'skip' ? '' : ` · ${r.model} · ${r.size.toUpperCase()}`; 176 lines.push(` #${r.id} ${r.verdict}${meta} ${clip(r.title, 60)}${r.moved ? ` → ${path.basename(r.moved)}` : ''}`); 177 } 178 for (const r of results.filter((x) => x.error)) lines.push(` #${r.id} not saved: ${r.error}`); 179 const left = counts(load()).inbox; 180 if (left) lines.push(`${left} still in inbox.`); 181 return lines.join('\n'); 182 } 183 184 export function headlessSummary(out) { 185 return out.message || summarizeTriage(out.results, `${out.model}, ${out.tokens.input} in / ${out.tokens.output} out tokens`); 186 } 187 188 /** The board after a triage, so the user sees every idea in its new place. */ 189 function withBoard(text) { 190 return `${text}\n\n${renderBoard(load(), { cwd: currentProject(), hints: true })}`; 191 } 192 193 // --------------------------------------------------------------------------------------------- 194 // Tool handlers return plain text for the model. 195 196 const handlers = { 197 async idea_add({ text, tags, project }) { 198 const results = await archive.add(splitIdeas(text || ''), { source: 'mcp', project: project || currentProject(), tags }); 199 return results.queued ? `Saved on this machine. ${archive.queuedText(results)}` : renderAdded(results, load()); 200 }, 201 202 async idea_list({ id, filter = 'open', query = '', here = false, limit = 0, full = false, semantic = false, groups = false }) { 203 const db = await archive.fresh(); 204 if (id != null) { 205 const idea = findIdea(db, id); 206 if (!idea) throw new Error(`no idea #${id}`); 207 return renderIdea(idea); 208 } 209 const project = here ? currentProject() : null; 210 if (semantic && query) { 211 const found = await embed.find(db, query, { filter, project, limit: limit || 10 }); 212 if (!full) return renderFound(found, query, { cwd: currentProject() }); 213 return found.results.length ? found.results.map((r) => renderIdea(r.idea)).join('\n\n') : 'No ideas match.'; 214 } 215 if (groups) { 216 const ideas = listIdeas(db, { filter, query, limit, project }); 217 return renderGroups(await embed.groupIdeas(ideas), ideas, { cwd: currentProject(), scope: filter }); 218 } 219 if (full) { 220 const ideas = listIdeas(db, { filter, query, limit, project }); 221 return ideas.length ? ideas.map(renderIdea).join('\n\n') : 'No ideas match.'; 222 } 223 return renderBoard(db, { filter, query, limit, project, cwd: currentProject() }); 224 }, 225 226 async idea_triage({ verdicts, ids, limit, by, headless = false, model }) { 227 if (headless) { 228 return withBoard(headlessSummary(await headlessTriage({ model, limit: limit || 20 }))); 229 } 230 if (Array.isArray(verdicts) && verdicts.length) { 231 const projects = knownProjects(await archive.fresh()); 232 const results = await archive.triage(verdicts, { by: by ? clip(by, 40) : 'claude', projects }); 233 return results.queued ? archive.queuedText(results) : withBoard(summarizeTriage(results)); 234 } 235 limit ||= 30; 236 const db = await archive.fresh(); 237 const pending = pendingIdeas(db, { ids, limit }); 238 if (!pending.length) return 'Nothing to triage: the inbox is empty.'; 239 return ( 240 triagePrompt(db, pending, knownProjects(db)) + 241 '\n\nSave every verdict in ONE idea_triage call: ' + 242 '{"verdicts":[{"id":1,"verdict":"do","impact":3,"size":"s","model":"sonnet","title":"...","why":"...","brief":"...","project":"..."}]}' 243 ); 244 }, 245 246 async idea_update({ id, ...patch }) { 247 const idea = await archive.update(id, patch); 248 if (idea.queued) return archive.queuedText(idea); 249 const bits = [`#${idea.id} ${clip(idea.title, 60)} · ${lane(idea)}`]; 250 if (patch.model) bits.push(`model ${idea.triage.model}`); 251 if (patch.note) bits.push('note added'); 252 return `Updated ${bits.join(' · ')}`; 253 }, 254 255 async idea_next({ id, here = false, triage = false }) { 256 const out = []; 257 if (triage) { 258 try { 259 const res = await triageFirst({ id }); 260 if (res) out.push(headlessSummary(res)); 261 } catch (e) { 262 out.push(`Triage failed: ${e.message}`); 263 } 264 } 265 const db = await archive.fresh(); 266 let idea; 267 if (id != null) { 268 idea = findIdea(db, id); 269 if (!idea) throw new Error(`no idea #${id}`); 270 } else { 271 idea = pickNext(db, { project: currentProject(), only: here }); 272 } 273 if (!idea) { 274 const c = counts(db); 275 out.push( 276 c.inbox 277 ? `No idea is ready: ${c.inbox} untriaged in the inbox.${triage ? '' : ' Pass triage: true to triage them first.'}` 278 : `The queue is empty${here ? ' for this project' : ''}.`, 279 ); 280 return out.join('\n\n'); 281 } 282 const t = idea.triage; 283 const lines = [renderIdea(idea), '']; 284 if (!t) lines.push('recommended model: none yet (untriaged)'); 285 else lines.push(`recommended model: ${t.model}`); 286 // Ideas are saved from any session, so the recorded folder can be a scratch folder that is gone. 287 const dir = idea.project; 288 lines.push(`project dir: ${dir ? `${dir} (${fs.existsSync(dir) ? 'exists' : 'does not exist'})` : '(none recorded)'}`); 289 out.push(lines.join('\n')); 290 // The whole queue, so the caller can choose an idea that fits its chat better than the first one. 291 if (id == null) out.push(`The queue:\n${renderBoard(db, { cwd: currentProject() })}`); 292 return out.join('\n\n'); 293 }, 294 295 async idea_remove({ ids = [] }) { 296 if (!ids.length) throw new Error('pass the ids to delete'); 297 const gone = await archive.remove(ids); 298 if (gone.queued) return archive.queuedText(gone); 299 return [`Removed ${gone.length} idea${gone.length === 1 ? '' : 's'}.`, ...gone.map(renderIdea)].join('\n\n'); 300 }, 301 }; 302 303 // --------------------------------------------------------------------------------------------- 304 // JSON-RPC plumbing 305 306 function send(msg) { 307 process.stdout.write(JSON.stringify(msg) + '\n'); 308 } 309 310 function result(id, value) { 311 return { jsonrpc: '2.0', id, result: value }; 312 } 313 314 function error(id, code, message) { 315 return { jsonrpc: '2.0', id, error: { code, message } }; 316 } 317 318 async function handleRequest(msg, { prompts }) { 319 const { id, method, params = {} } = msg; 320 switch (method) { 321 case 'initialize': { 322 const asked = params.protocolVersion; 323 const protocolVersion = PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0]; 324 const capabilities = { tools: { listChanged: false } }; 325 if (prompts) capabilities.prompts = { listChanged: false }; 326 return result(id, { protocolVersion, capabilities, serverInfo: { name: 'ideamine', version: VERSION }, instructions: INSTRUCTIONS }); 327 } 328 case 'ping': 329 return result(id, {}); 330 case 'tools/list': 331 return result(id, { tools: TOOLS }); 332 case 'tools/call': { 333 const handler = handlers[params.name]; 334 if (!handler) return error(id, -32602, `unknown tool: ${params.name}`); 335 try { 336 const text = await handler(params.arguments || {}); 337 return result(id, { content: [{ type: 'text', text }] }); 338 } catch (e) { 339 return result(id, { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true }); 340 } 341 } 342 case 'prompts/list': 343 return result(id, { prompts: prompts ? PROMPTS : [] }); 344 case 'prompts/get': { 345 const p = prompts && PROMPTS.find((x) => x.name === params.name); 346 if (!p) return error(id, -32602, `unknown prompt: ${params.name}`); 347 const args = Object.values(params.arguments || {}).filter(Boolean).join(' '); 348 return result(id, { 349 description: p.description, 350 messages: [{ role: 'user', content: { type: 'text', text: skillBody(p.name, args) } }], 351 }); 352 } 353 case 'resources/list': 354 return result(id, { resources: [] }); 355 case 'resources/templates/list': 356 return result(id, { resourceTemplates: [] }); 357 case 'logging/setLevel': 358 return result(id, {}); 359 default: 360 return error(id, -32601, `method not found: ${method}`); 361 } 362 } 363 364 async function handleMessage(msg, opts) { 365 if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') return error(null, -32600, 'invalid request'); 366 if (typeof msg.method !== 'string') return null; // a response to a request we never send 367 if (msg.id === undefined) return null; // notification (initialized, cancelled, ...) 368 try { 369 return await handleRequest(msg, opts); 370 } catch (e) { 371 return error(msg.id, -32603, e.message); 372 } 373 } 374 375 async function handleLine(line, opts) { 376 let msg; 377 try { 378 msg = JSON.parse(line); 379 } catch { 380 return error(null, -32700, 'parse error'); 381 } 382 if (!Array.isArray(msg)) return handleMessage(msg, opts); 383 const replies = (await Promise.all(msg.map((m) => handleMessage(m, opts)))).filter(Boolean); 384 return replies.length ? replies : null; 385 } 386 387 /** Serve MCP on stdin/stdout. `prompts: false` when running as a plugin (skills cover slash commands). */ 388 export function serve({ prompts = true } = {}) { 389 const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); 390 const inFlight = new Set(); 391 // Requests run concurrently (a headless triage must not stall a ping); replies carry their ids. 392 rl.on('line', (line) => { 393 if (!line.trim()) return; 394 const job = handleLine(line, { prompts }).then((reply) => reply && send(reply)); 395 inFlight.add(job); 396 job.finally(() => inFlight.delete(job)); 397 }); 398 rl.on('close', () => Promise.allSettled([...inFlight]).then(() => process.exit(0))); 399 }