src/mcp.js (19328 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-pipeline', description: 'Build the next idea through the agent pipeline', arguments: [{ name: 'id', required: false }] }, 158 { name: 'ideas-all', description: 'Do every idea that fits this chat', arguments: [] }, 159 { name: 'ideas-sort', description: 'Triage the inbox now and show the queue', arguments: [] }, 160 { name: 'ideas-watch', description: 'Turn the background watcher on or off', arguments: [{ name: 'off', required: false }] }, 161 { name: 'ideas-web', description: 'Start or stop the dashboard with buttons on this PC', arguments: [{ name: 'off', required: false }] }, 162 { name: 'ideas-sync', description: 'Share one archive between your machines through an ideamine server', arguments: [{ name: 'url', required: false }] }, 163 { name: 'pipeline', description: 'Run the agent pipeline for a feature request: research, storyboard, plan, engineers, test, validate', arguments: [{ name: 'request', required: true }] }, 164 ]; 165 166 function skillBody(name, args) { 167 const file = new URL(`skills/${name}/SKILL.md`, ROOT); 168 const text = fs.readFileSync(fileURLToPath(file), 'utf8').replace(/^---[\s\S]*?\n---\s*\n/, ''); 169 return text.replaceAll('$ARGUMENTS', args || '').trim(); 170 } 171 172 function summarizeTriage(results, how = '') { 173 const ok = results.filter((r) => !r.error); 174 const tally = ['do', 'maybe', 'skip'].map((v) => `${ok.filter((r) => r.verdict === v).length} ${v}`).join(' · '); 175 const lines = [`Saved ${ok.length} verdict${ok.length === 1 ? '' : 's'}: ${tally}${how ? ` (${how})` : ''}`]; 176 for (const r of ok) { 177 const meta = r.verdict === 'skip' ? '' : ` · ${r.model} · ${r.size.toUpperCase()}`; 178 lines.push(` #${r.id} ${r.verdict}${meta} ${clip(r.title, 60)}${r.moved ? ` → ${path.basename(r.moved)}` : ''}`); 179 } 180 for (const r of results.filter((x) => x.error)) lines.push(` #${r.id} not saved: ${r.error}`); 181 const left = counts(load()).inbox; 182 if (left) lines.push(`${left} still in inbox.`); 183 return lines.join('\n'); 184 } 185 186 export function headlessSummary(out) { 187 return out.message || summarizeTriage(out.results, `${out.model}, ${out.tokens.input} in / ${out.tokens.output} out tokens`); 188 } 189 190 /** The board after a triage, so the user sees every idea in its new place. */ 191 function withBoard(text) { 192 return `${text}\n\n${renderBoard(load(), { cwd: currentProject(), hints: true })}`; 193 } 194 195 // --------------------------------------------------------------------------------------------- 196 // Tool handlers return plain text for the model. 197 198 const handlers = { 199 async idea_add({ text, tags, project }) { 200 const results = await archive.add(splitIdeas(text || ''), { source: 'mcp', project: project || currentProject(), tags }); 201 return results.queued ? `Saved on this machine. ${archive.queuedText(results)}` : renderAdded(results, load()); 202 }, 203 204 async idea_list({ id, filter = 'open', query = '', here = false, limit = 0, full = false, semantic = false, groups = false }) { 205 const db = await archive.fresh(); 206 if (id != null) { 207 const idea = findIdea(db, id); 208 if (!idea) throw new Error(`no idea #${id}`); 209 return renderIdea(idea); 210 } 211 const project = here ? currentProject() : null; 212 if (semantic && query) { 213 const found = await embed.find(db, query, { filter, project, limit: limit || 10 }); 214 if (!full) return renderFound(found, query, { cwd: currentProject() }); 215 return found.results.length ? found.results.map((r) => renderIdea(r.idea)).join('\n\n') : 'No ideas match.'; 216 } 217 if (groups) { 218 const ideas = listIdeas(db, { filter, query, limit, project }); 219 return renderGroups(await embed.groupIdeas(ideas), ideas, { cwd: currentProject(), scope: filter }); 220 } 221 if (full) { 222 const ideas = listIdeas(db, { filter, query, limit, project }); 223 return ideas.length ? ideas.map(renderIdea).join('\n\n') : 'No ideas match.'; 224 } 225 return renderBoard(db, { filter, query, limit, project, cwd: currentProject() }); 226 }, 227 228 async idea_triage({ verdicts, ids, limit, by, headless = false, model }) { 229 if (headless) { 230 return withBoard(headlessSummary(await headlessTriage({ model, limit: limit || 20 }))); 231 } 232 if (Array.isArray(verdicts) && verdicts.length) { 233 const projects = knownProjects(await archive.fresh()); 234 const results = await archive.triage(verdicts, { by: by ? clip(by, 40) : 'claude', projects }); 235 return results.queued ? archive.queuedText(results) : withBoard(summarizeTriage(results)); 236 } 237 limit ||= 30; 238 const db = await archive.fresh(); 239 const pending = pendingIdeas(db, { ids, limit }); 240 if (!pending.length) return 'Nothing to triage: the inbox is empty.'; 241 return ( 242 triagePrompt(db, pending, knownProjects(db)) + 243 '\n\nSave every verdict in ONE idea_triage call: ' + 244 '{"verdicts":[{"id":1,"verdict":"do","impact":3,"size":"s","model":"sonnet","title":"...","why":"...","brief":"...","project":"..."}]}' 245 ); 246 }, 247 248 async idea_update({ id, ...patch }) { 249 const idea = await archive.update(id, patch); 250 if (idea.queued) return archive.queuedText(idea); 251 const bits = [`#${idea.id} ${clip(idea.title, 60)} · ${lane(idea)}`]; 252 if (patch.model) bits.push(`model ${idea.triage.model}`); 253 if (patch.note) bits.push('note added'); 254 return `Updated ${bits.join(' · ')}`; 255 }, 256 257 async idea_next({ id, here = false, triage = false }) { 258 const out = []; 259 if (triage) { 260 try { 261 const res = await triageFirst({ id }); 262 if (res) out.push(headlessSummary(res)); 263 } catch (e) { 264 out.push(`Triage failed: ${e.message}`); 265 } 266 } 267 const db = await archive.fresh(); 268 let idea; 269 if (id != null) { 270 idea = findIdea(db, id); 271 if (!idea) throw new Error(`no idea #${id}`); 272 } else { 273 idea = pickNext(db, { project: currentProject(), only: here }); 274 } 275 if (!idea) { 276 const c = counts(db); 277 out.push( 278 c.inbox 279 ? `No idea is ready: ${c.inbox} untriaged in the inbox.${triage ? '' : ' Pass triage: true to triage them first.'}` 280 : `The queue is empty${here ? ' for this project' : ''}.`, 281 ); 282 return out.join('\n\n'); 283 } 284 const t = idea.triage; 285 const lines = [renderIdea(idea), '']; 286 if (!t) lines.push('recommended model: none yet (untriaged)'); 287 else lines.push(`recommended model: ${t.model}`); 288 // Ideas are saved from any session, so the recorded folder can be a scratch folder that is gone. 289 const dir = idea.project; 290 lines.push(`project dir: ${dir ? `${dir} (${fs.existsSync(dir) ? 'exists' : 'does not exist'})` : '(none recorded)'}`); 291 out.push(lines.join('\n')); 292 // The whole queue, so the caller can choose an idea that fits its chat better than the first one. 293 if (id == null) out.push(`The queue:\n${renderBoard(db, { cwd: currentProject() })}`); 294 return out.join('\n\n'); 295 }, 296 297 async idea_remove({ ids = [] }) { 298 if (!ids.length) throw new Error('pass the ids to delete'); 299 const gone = await archive.remove(ids); 300 if (gone.queued) return archive.queuedText(gone); 301 return [`Removed ${gone.length} idea${gone.length === 1 ? '' : 's'}.`, ...gone.map(renderIdea)].join('\n\n'); 302 }, 303 }; 304 305 // --------------------------------------------------------------------------------------------- 306 // JSON-RPC plumbing 307 308 function send(msg) { 309 process.stdout.write(JSON.stringify(msg) + '\n'); 310 } 311 312 function result(id, value) { 313 return { jsonrpc: '2.0', id, result: value }; 314 } 315 316 function error(id, code, message) { 317 return { jsonrpc: '2.0', id, error: { code, message } }; 318 } 319 320 async function handleRequest(msg, { prompts }) { 321 const { id, method, params = {} } = msg; 322 switch (method) { 323 case 'initialize': { 324 const asked = params.protocolVersion; 325 const protocolVersion = PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0]; 326 const capabilities = { tools: { listChanged: false } }; 327 if (prompts) capabilities.prompts = { listChanged: false }; 328 return result(id, { protocolVersion, capabilities, serverInfo: { name: 'ideamine', version: VERSION }, instructions: INSTRUCTIONS }); 329 } 330 case 'ping': 331 return result(id, {}); 332 case 'tools/list': 333 return result(id, { tools: TOOLS }); 334 case 'tools/call': { 335 const handler = handlers[params.name]; 336 if (!handler) return error(id, -32602, `unknown tool: ${params.name}`); 337 try { 338 const text = await handler(params.arguments || {}); 339 return result(id, { content: [{ type: 'text', text }] }); 340 } catch (e) { 341 return result(id, { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true }); 342 } 343 } 344 case 'prompts/list': 345 return result(id, { prompts: prompts ? PROMPTS : [] }); 346 case 'prompts/get': { 347 const p = prompts && PROMPTS.find((x) => x.name === params.name); 348 if (!p) return error(id, -32602, `unknown prompt: ${params.name}`); 349 const args = Object.values(params.arguments || {}).filter(Boolean).join(' '); 350 return result(id, { 351 description: p.description, 352 messages: [{ role: 'user', content: { type: 'text', text: skillBody(p.name, args) } }], 353 }); 354 } 355 case 'resources/list': 356 return result(id, { resources: [] }); 357 case 'resources/templates/list': 358 return result(id, { resourceTemplates: [] }); 359 case 'logging/setLevel': 360 return result(id, {}); 361 default: 362 return error(id, -32601, `method not found: ${method}`); 363 } 364 } 365 366 async function handleMessage(msg, opts) { 367 if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') return error(null, -32600, 'invalid request'); 368 if (typeof msg.method !== 'string') return null; // a response to a request we never send 369 if (msg.id === undefined) return null; // notification (initialized, cancelled, ...) 370 try { 371 return await handleRequest(msg, opts); 372 } catch (e) { 373 return error(msg.id, -32603, e.message); 374 } 375 } 376 377 async function handleLine(line, opts) { 378 let msg; 379 try { 380 msg = JSON.parse(line); 381 } catch { 382 return error(null, -32700, 'parse error'); 383 } 384 if (!Array.isArray(msg)) return handleMessage(msg, opts); 385 const replies = (await Promise.all(msg.map((m) => handleMessage(m, opts)))).filter(Boolean); 386 return replies.length ? replies : null; 387 } 388 389 /** Serve MCP on stdin/stdout. `prompts: false` when running as a plugin (skills cover slash commands). */ 390 export function serve({ prompts = true } = {}) { 391 const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); 392 const inFlight = new Set(); 393 // Requests run concurrently (a headless triage must not stall a ping); replies carry their ids. 394 rl.on('line', (line) => { 395 if (!line.trim()) return; 396 const job = handleLine(line, { prompts }).then((reply) => reply && send(reply)); 397 inFlight.add(job); 398 job.finally(() => inFlight.delete(job)); 399 }); 400 rl.on('close', () => Promise.allSettled([...inFlight]).then(() => process.exit(0))); 401 }