sticky-note-cli 2.6.16 → 2.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,788 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sticky-note MCP server — stdio-based
4
+ *
5
+ * Implements Model Context Protocol (JSON-RPC 2.0 over stdin/stdout)
6
+ * with 8 tools for AI assistants:
7
+ *
8
+ * get_session_context(id) — full thread payload by ID
9
+ * get_stuck_threads() — all stuck threads
10
+ * search_threads(query) — keyword search across threads
11
+ * get_audit_trail(...) — query per-user JSONL audit logs
12
+ * get_presence() — current presence data
13
+ * check_overlaps(files) — overlap detection for edit gating
14
+ * get_environment_status() — environment sync status
15
+ * get_thread_context_for_files(files) — thread attribution for files
16
+ *
17
+ * Zero external dependencies. Node >= 16.
18
+ *
19
+ * Usage:
20
+ * node bin/mcp-server.js (uses cwd)
21
+ * node bin/mcp-server.js --project /path (explicit project root)
22
+ */
23
+
24
+ const fs = require("fs");
25
+ const path = require("path");
26
+
27
+ // ──────────────────────────────────────────────
28
+ // Project root resolution
29
+ // ──────────────────────────────────────────────
30
+
31
+ let PROJECT_ROOT = process.cwd();
32
+ const args = process.argv.slice(2);
33
+ for (let i = 0; i < args.length; i++) {
34
+ if (args[i] === "--project" && args[i + 1]) {
35
+ PROJECT_ROOT = path.resolve(args[i + 1]);
36
+ }
37
+ }
38
+
39
+ function stickyDir() {
40
+ return path.join(PROJECT_ROOT, ".sticky-note");
41
+ }
42
+
43
+ // ──────────────────────────────────────────────
44
+ // File helpers
45
+ // ──────────────────────────────────────────────
46
+
47
+ function readJsonSafe(filePath, fallback) {
48
+ try {
49
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
50
+ } catch {
51
+ return fallback;
52
+ }
53
+ }
54
+
55
+ function getMemory() {
56
+ return readJsonSafe(path.join(stickyDir(), "sticky-note.json"), {
57
+ version: "2",
58
+ threads: [],
59
+ });
60
+ }
61
+
62
+ function getConfig() {
63
+ return readJsonSafe(path.join(stickyDir(), "sticky-note-config.json"), {
64
+ stale_days: 14,
65
+ mcp_servers: [],
66
+ skills: [],
67
+ conventions: [],
68
+ });
69
+ }
70
+
71
+ function getThreads() {
72
+ return getMemory().threads || [];
73
+ }
74
+
75
+ /**
76
+ * Read all per-user presence files from .sticky-note/presence/
77
+ * Returns { username: { active_files, last_seen } }
78
+ */
79
+ function getAllPresence() {
80
+ const presenceDir = path.join(stickyDir(), "presence");
81
+ const result = {};
82
+ if (!fs.existsSync(presenceDir)) return result;
83
+
84
+ for (const file of fs.readdirSync(presenceDir)) {
85
+ if (!file.endsWith(".json")) continue;
86
+ const user = file.replace(".json", "");
87
+ result[user] = readJsonSafe(path.join(presenceDir, file), {});
88
+ }
89
+ return result;
90
+ }
91
+
92
+ /**
93
+ * Read all per-user audit files from .sticky-note/audit/
94
+ * Returns array of parsed entries across all users.
95
+ */
96
+ function getAllAuditEntries(filters) {
97
+ const auditDir = path.join(stickyDir(), "audit");
98
+ if (!fs.existsSync(auditDir)) return [];
99
+
100
+ const { file, user, since, tool, session, type } = filters || {};
101
+ const entries = [];
102
+
103
+ const auditFiles = fs.readdirSync(auditDir).filter((f) => f.endsWith(".jsonl"));
104
+
105
+ // If filtering by user, only read that user's file
106
+ const filesToRead = user
107
+ ? auditFiles.filter((f) => f === `${user}.jsonl`)
108
+ : auditFiles;
109
+
110
+ for (const auditFile of filesToRead) {
111
+ const content = fs.readFileSync(path.join(auditDir, auditFile), "utf-8");
112
+ const lines = content.split("\n").filter((l) => l.trim());
113
+
114
+ for (const line of lines) {
115
+ let entry;
116
+ try {
117
+ entry = JSON.parse(line);
118
+ } catch {
119
+ continue;
120
+ }
121
+
122
+ if (file && !(entry.file || "").includes(file)) continue;
123
+ if (user && entry.user !== user) continue;
124
+ if (session && entry.session_id !== session) continue;
125
+ if (tool && entry.tool !== tool) continue;
126
+ if (type && entry.type !== type) continue;
127
+ if (since) {
128
+ const ts = entry.ts || entry.timestamp || "";
129
+ if (ts < since) continue;
130
+ }
131
+
132
+ entries.push(entry);
133
+ }
134
+ }
135
+
136
+ return entries;
137
+ }
138
+
139
+ // ──────────────────────────────────────────────
140
+ // Tool implementations — Core (5 original)
141
+ // ──────────────────────────────────────────────
142
+
143
+ function toolGetSessionContext(params) {
144
+ const { id } = params;
145
+ if (!id) {
146
+ return { error: "Missing required parameter: id" };
147
+ }
148
+
149
+ const threads = getThreads();
150
+ const thread = threads.find((t) => t.id === id || t.session_id === id);
151
+
152
+ if (!thread) {
153
+ return { error: `No thread found with id: ${id}` };
154
+ }
155
+
156
+ return { thread };
157
+ }
158
+
159
+ function toolGetStuckThreads() {
160
+ const threads = getThreads();
161
+ const stuck = threads.filter((t) => t.status === "stuck");
162
+
163
+ return {
164
+ count: stuck.length,
165
+ threads: stuck,
166
+ };
167
+ }
168
+
169
+ function toolSearchThreads(params) {
170
+ const { query } = params;
171
+ if (!query) {
172
+ return { error: "Missing required parameter: query" };
173
+ }
174
+
175
+ const threads = getThreads();
176
+ const q = query.toLowerCase();
177
+ const matches = threads.filter((t) => {
178
+ if (t.status === "expired") return false;
179
+
180
+ const searchable = [
181
+ t.id,
182
+ t.user,
183
+ t.last_note,
184
+ t.narrative,
185
+ t.handoff_summary,
186
+ t.branch,
187
+ ...(t.files_touched || []),
188
+ ...(t.failed_approaches || []).map((f) =>
189
+ typeof f === "string" ? f : `${f.description} ${f.error}`
190
+ ),
191
+ ]
192
+ .filter(Boolean)
193
+ .join(" ")
194
+ .toLowerCase();
195
+
196
+ return q.split(/\s+/).every((word) => searchable.includes(word));
197
+ });
198
+
199
+ return {
200
+ query,
201
+ count: matches.length,
202
+ threads: matches,
203
+ };
204
+ }
205
+
206
+ function toolGetAuditTrail(params) {
207
+ const { file, user, since, tool, session, limit } = params || {};
208
+
209
+ const entries = getAllAuditEntries({ file, user, since, tool, session });
210
+
211
+ if (entries.length === 0) {
212
+ return {
213
+ total_matches: 0,
214
+ returned: 0,
215
+ entries: [],
216
+ };
217
+ }
218
+
219
+ const cap = limit || 100;
220
+ const display = entries.slice(-cap).reverse();
221
+
222
+ return {
223
+ total_matches: entries.length,
224
+ returned: display.length,
225
+ entries: display,
226
+ };
227
+ }
228
+
229
+ function toolGetPresence() {
230
+ const presence = getAllPresence();
231
+ const entries = Object.entries(presence);
232
+ const now = new Date();
233
+
234
+ const active = entries
235
+ .filter(([_user, data]) => {
236
+ const lastSeen = new Date(data.last_seen || 0);
237
+ return now - lastSeen < 15 * 60 * 1000;
238
+ })
239
+ .map(([user, data]) => ({
240
+ user,
241
+ active_files: data.active_files || [],
242
+ last_seen: data.last_seen,
243
+ }));
244
+
245
+ return {
246
+ active_count: active.length,
247
+ users: active,
248
+ stale_count: entries.length - active.length,
249
+ };
250
+ }
251
+
252
+ // ──────────────────────────────────────────────
253
+ // Tool implementations — New (3 notification/env)
254
+ // ──────────────────────────────────────────────
255
+
256
+ function toolCheckOverlaps(params) {
257
+ const { files } = params;
258
+ if (!files || !Array.isArray(files) || files.length === 0) {
259
+ return { error: "Missing required parameter: files (array of file paths)" };
260
+ }
261
+
262
+ const threads = getThreads();
263
+ const presence = getAllPresence();
264
+ const currentUser = process.env.USER || process.env.USERNAME || "unknown";
265
+ const normalizedFiles = files.map((f) => f.replace(/\\/g, "/"));
266
+
267
+ const overlaps = [];
268
+
269
+ // Check threads (open/stuck) that touch the same files
270
+ for (const thread of threads) {
271
+ if (thread.status !== "open" && thread.status !== "stuck") continue;
272
+ if (thread.user === currentUser) continue;
273
+
274
+ const threadFiles = (thread.files_touched || []).map((f) =>
275
+ f.replace(/\\/g, "/")
276
+ );
277
+ const shared = normalizedFiles.filter((f) =>
278
+ threadFiles.some((tf) => tf === f || tf.includes(f) || f.includes(tf))
279
+ );
280
+
281
+ if (shared.length > 0) {
282
+ overlaps.push({
283
+ user: thread.user,
284
+ thread_id: thread.id,
285
+ status: thread.status,
286
+ files: shared,
287
+ narrative: thread.narrative || thread.last_note || "",
288
+ failed_approaches: (thread.failed_approaches || []).map((f) =>
289
+ typeof f === "string" ? f : f.description
290
+ ),
291
+ severity: thread.status === "stuck" ? "high" : "medium",
292
+ });
293
+ }
294
+ }
295
+
296
+ // Check presence — other users actively working on the same files
297
+ const now = new Date();
298
+ for (const [user, data] of Object.entries(presence)) {
299
+ if (user === currentUser) continue;
300
+ const lastSeen = new Date(data.last_seen || 0);
301
+ if (now - lastSeen > 15 * 60 * 1000) continue;
302
+
303
+ const presenceFiles = (data.active_files || []).map((f) =>
304
+ f.replace(/\\/g, "/")
305
+ );
306
+ const shared = normalizedFiles.filter((f) =>
307
+ presenceFiles.some((pf) => pf === f || pf.includes(f) || f.includes(pf))
308
+ );
309
+
310
+ if (shared.length > 0) {
311
+ // Don't duplicate if already captured via thread
312
+ const alreadyCovered = overlaps.some((o) => o.user === user);
313
+ if (!alreadyCovered) {
314
+ overlaps.push({
315
+ user,
316
+ thread_id: null,
317
+ status: "active",
318
+ files: shared,
319
+ narrative: `${user} is currently working on these files`,
320
+ failed_approaches: [],
321
+ severity: "low",
322
+ });
323
+ }
324
+ }
325
+ }
326
+
327
+ if (overlaps.length === 0) {
328
+ return {
329
+ overlaps: [],
330
+ warning: null,
331
+ action: "proceed",
332
+ };
333
+ }
334
+
335
+ const highSeverity = overlaps.filter((o) => o.severity === "high");
336
+ const warning =
337
+ highSeverity.length > 0
338
+ ? `WARNING: ${highSeverity.map((o) => o.user).join(", ")} ${highSeverity.length === 1 ? "has" : "have"} STUCK ${highSeverity.length === 1 ? "thread" : "threads"} on files you are about to edit. Consider coordinating before proceeding.`
339
+ : `NOTE: ${overlaps.map((o) => o.user).join(", ")} ${overlaps.length === 1 ? "is" : "are"} working on overlapping files. Proceed with awareness.`;
340
+
341
+ return {
342
+ overlaps,
343
+ warning,
344
+ action: "display_to_user",
345
+ };
346
+ }
347
+
348
+ function toolGetEnvironmentStatus() {
349
+ const envDir = path.join(stickyDir(), "environment");
350
+ const mcpPath = path.join(PROJECT_ROOT, ".mcp.json");
351
+
352
+ // No environment directory — feature not set up
353
+ if (!fs.existsSync(envDir)) {
354
+ return {
355
+ status: "not_configured",
356
+ message: "No .sticky-note/environment/ directory found. Environment sync is not set up for this repo.",
357
+ action: "none",
358
+ };
359
+ }
360
+
361
+ const manifest = readJsonSafe(path.join(envDir, "manifest.json"), {});
362
+ const mcpConfig = readJsonSafe(mcpPath, { mcpServers: {} });
363
+ const registeredServers = Object.keys(mcpConfig.mcpServers || {});
364
+
365
+ const provisioned = { mcp_servers: [], skills: [], agents: [], commands: [] };
366
+ const missing = { mcp_servers: [] };
367
+
368
+ // Check MCP servers
369
+ if (manifest.mcp_servers) {
370
+ for (const [name, config] of Object.entries(manifest.mcp_servers)) {
371
+ if (registeredServers.includes(name)) {
372
+ provisioned.mcp_servers.push(name);
373
+ } else {
374
+ // Figure out why it's missing
375
+ const envEntries = config.env || {};
376
+ const missingSecrets = Object.entries(envEntries)
377
+ .filter(([_, val]) => typeof val === "string" && val.startsWith("${") && val.endsWith("}"))
378
+ .map(([key, val]) => {
379
+ const envName = val.slice(2, -1);
380
+ const envVarMeta = (manifest.env_vars || {})[envName] || {};
381
+ return {
382
+ env_var: envName,
383
+ description: envVarMeta.description || key,
384
+ docs_url: envVarMeta.docs_url || null,
385
+ required: envVarMeta.required !== false,
386
+ };
387
+ });
388
+
389
+ missing.mcp_servers.push({
390
+ name,
391
+ reason: missingSecrets.length > 0 ? "missing_secret" : "not_provisioned",
392
+ secrets: missingSecrets,
393
+ description: config.description || name,
394
+ required: config.required !== false,
395
+ });
396
+ }
397
+ }
398
+ }
399
+
400
+ // Scan skills directory
401
+ const skillsDir = path.join(envDir, "skills");
402
+ if (fs.existsSync(skillsDir)) {
403
+ provisioned.skills = fs
404
+ .readdirSync(skillsDir)
405
+ .filter((f) => f.endsWith(".md"))
406
+ .map((f) => f.replace(".md", ""));
407
+ }
408
+
409
+ // Scan agents directory
410
+ const agentsDir = path.join(envDir, "agents");
411
+ if (fs.existsSync(agentsDir)) {
412
+ provisioned.agents = fs
413
+ .readdirSync(agentsDir)
414
+ .filter((f) => f.endsWith(".md"))
415
+ .map((f) => f.replace(".md", ""));
416
+ }
417
+
418
+ // Scan commands directory
419
+ const commandsDir = path.join(envDir, "commands");
420
+ if (fs.existsSync(commandsDir)) {
421
+ provisioned.commands = fs
422
+ .readdirSync(commandsDir)
423
+ .filter((f) => f.endsWith(".md"))
424
+ .map((f) => f.replace(".md", ""));
425
+ }
426
+
427
+ const hasMissing = missing.mcp_servers.length > 0;
428
+ const status = hasMissing ? "incomplete" : "complete";
429
+
430
+ const result = {
431
+ status,
432
+ provisioned,
433
+ action: hasMissing ? "inform_user_of_missing" : "none",
434
+ };
435
+
436
+ if (hasMissing) {
437
+ result.missing = missing;
438
+ const requiredMissing = missing.mcp_servers.filter((s) => s.required);
439
+ if (requiredMissing.length > 0) {
440
+ result.message = `${requiredMissing.length} required MCP server(s) need secrets. Run: npx sticky-note bootstrap`;
441
+ }
442
+ }
443
+
444
+ return result;
445
+ }
446
+
447
+ function toolGetThreadContextForFiles(params) {
448
+ const { files } = params;
449
+ if (!files || !Array.isArray(files) || files.length === 0) {
450
+ return { error: "Missing required parameter: files (array of file paths)" };
451
+ }
452
+
453
+ const threads = getThreads();
454
+ const normalizedFiles = files.map((f) => f.replace(/\\/g, "/"));
455
+
456
+ const fileContexts = {};
457
+
458
+ for (const file of normalizedFiles) {
459
+ const relatedThreads = threads
460
+ .filter((t) => {
461
+ if (t.status === "expired") return false;
462
+ const threadFiles = (t.files_touched || []).map((f) =>
463
+ f.replace(/\\/g, "/")
464
+ );
465
+ return threadFiles.some(
466
+ (tf) => tf === file || tf.includes(file) || file.includes(tf)
467
+ );
468
+ })
469
+ .map((t) => ({
470
+ id: t.id,
471
+ user: t.user,
472
+ status: t.status,
473
+ work_type: t.work_type || "unknown",
474
+ narrative: t.narrative || t.last_note || "",
475
+ failed_approaches: (t.failed_approaches || []).map((f) =>
476
+ typeof f === "string" ? f : { description: f.description, error: f.error }
477
+ ),
478
+ handoff_summary: t.handoff_summary || "",
479
+ branch: t.branch || "",
480
+ updated_at: t.updated_at || t.created_at || "",
481
+ }));
482
+
483
+ if (relatedThreads.length > 0) {
484
+ fileContexts[file] = {
485
+ thread_count: relatedThreads.length,
486
+ threads: relatedThreads,
487
+ };
488
+ }
489
+ }
490
+
491
+ return {
492
+ files_queried: files.length,
493
+ files_with_context: Object.keys(fileContexts).length,
494
+ contexts: fileContexts,
495
+ };
496
+ }
497
+
498
+ // ──────────────────────────────────────────────
499
+ // Tool registry
500
+ // ──────────────────────────────────────────────
501
+
502
+ const TOOLS = {
503
+ get_session_context: {
504
+ description:
505
+ "Get full thread payload for a session by ID. Returns all fields including narrative, failed_approaches, and handoff_summary.",
506
+ inputSchema: {
507
+ type: "object",
508
+ properties: {
509
+ id: {
510
+ type: "string",
511
+ description: "Thread ID or session ID to look up",
512
+ },
513
+ },
514
+ required: ["id"],
515
+ },
516
+ handler: toolGetSessionContext,
517
+ },
518
+ get_stuck_threads: {
519
+ description:
520
+ "Get all threads with status 'stuck'. Returns full payloads including failed approaches and last notes. Call at session start to check for team blockers.",
521
+ inputSchema: {
522
+ type: "object",
523
+ properties: {},
524
+ },
525
+ handler: toolGetStuckThreads,
526
+ },
527
+ search_threads: {
528
+ description:
529
+ "Search across all non-expired threads by keyword. Searches ID, user, notes, narrative, files, failed approaches, and branch.",
530
+ inputSchema: {
531
+ type: "object",
532
+ properties: {
533
+ query: {
534
+ type: "string",
535
+ description:
536
+ "Search query — all words must match (AND logic). Searches across thread fields.",
537
+ },
538
+ },
539
+ required: ["query"],
540
+ },
541
+ handler: toolSearchThreads,
542
+ },
543
+ get_audit_trail: {
544
+ description:
545
+ "Query the per-user JSONL audit logs. Filter by file, user, session, tool, or date. Returns most recent entries first.",
546
+ inputSchema: {
547
+ type: "object",
548
+ properties: {
549
+ file: {
550
+ type: "string",
551
+ description: "Filter entries by file path (substring match)",
552
+ },
553
+ user: {
554
+ type: "string",
555
+ description: "Filter by exact username",
556
+ },
557
+ since: {
558
+ type: "string",
559
+ description: "ISO date — only entries after this timestamp",
560
+ },
561
+ tool: {
562
+ type: "string",
563
+ description: "Filter by tool name (Edit, Read, Write, etc.)",
564
+ },
565
+ session: {
566
+ type: "string",
567
+ description: "Filter by session ID",
568
+ },
569
+ limit: {
570
+ type: "number",
571
+ description: "Max entries to return (default: 100)",
572
+ },
573
+ },
574
+ },
575
+ handler: toolGetAuditTrail,
576
+ },
577
+ get_presence: {
578
+ description:
579
+ "Get current developer presence — who is actively working and on which files. Only shows users seen in the last 15 minutes.",
580
+ inputSchema: {
581
+ type: "object",
582
+ properties: {},
583
+ },
584
+ handler: toolGetPresence,
585
+ },
586
+ check_overlaps: {
587
+ description:
588
+ "Check if given files overlap with other users' open/stuck threads or active presence. Call BEFORE editing files to detect conflicts. Returns severity levels and warnings.",
589
+ inputSchema: {
590
+ type: "object",
591
+ properties: {
592
+ files: {
593
+ type: "array",
594
+ items: { type: "string" },
595
+ description:
596
+ "Array of file paths you plan to edit. Checks against all open/stuck threads and active presence.",
597
+ },
598
+ },
599
+ required: ["files"],
600
+ },
601
+ handler: toolCheckOverlaps,
602
+ },
603
+ get_environment_status: {
604
+ description:
605
+ "Get environment sync status — which MCP servers, skills, agents, and commands are provisioned vs missing. Flags missing secrets with docs URLs. Call at session start to check for environment issues.",
606
+ inputSchema: {
607
+ type: "object",
608
+ properties: {},
609
+ },
610
+ handler: toolGetEnvironmentStatus,
611
+ },
612
+ get_thread_context_for_files: {
613
+ description:
614
+ "Get thread attribution for specific files — who worked on them, what happened, what failed. Call before editing files to understand prior work context.",
615
+ inputSchema: {
616
+ type: "object",
617
+ properties: {
618
+ files: {
619
+ type: "array",
620
+ items: { type: "string" },
621
+ description:
622
+ "Array of file paths to check for related thread context.",
623
+ },
624
+ },
625
+ required: ["files"],
626
+ },
627
+ handler: toolGetThreadContextForFiles,
628
+ },
629
+ };
630
+
631
+ // ──────────────────────────────────────────────
632
+ // MCP Protocol (JSON-RPC 2.0 over stdio)
633
+ // ──────────────────────────────────────────────
634
+
635
+ const SERVER_INFO = {
636
+ name: "sticky-note",
637
+ version: "3.0.0",
638
+ };
639
+
640
+ const CAPABILITIES = {
641
+ tools: {},
642
+ };
643
+
644
+ function handleRequest(msg) {
645
+ const { method, params, id } = msg;
646
+
647
+ switch (method) {
648
+ case "initialize":
649
+ return {
650
+ jsonrpc: "2.0",
651
+ id,
652
+ result: {
653
+ protocolVersion: "2024-11-05",
654
+ serverInfo: SERVER_INFO,
655
+ capabilities: CAPABILITIES,
656
+ },
657
+ };
658
+
659
+ case "notifications/initialized":
660
+ return null;
661
+
662
+ case "tools/list":
663
+ return {
664
+ jsonrpc: "2.0",
665
+ id,
666
+ result: {
667
+ tools: Object.entries(TOOLS).map(([name, tool]) => ({
668
+ name,
669
+ description: tool.description,
670
+ inputSchema: tool.inputSchema,
671
+ })),
672
+ },
673
+ };
674
+
675
+ case "tools/call": {
676
+ const toolName = params?.name;
677
+ const toolArgs = params?.arguments || {};
678
+ const tool = TOOLS[toolName];
679
+
680
+ if (!tool) {
681
+ return {
682
+ jsonrpc: "2.0",
683
+ id,
684
+ result: {
685
+ isError: true,
686
+ content: [
687
+ {
688
+ type: "text",
689
+ text: `Unknown tool: ${toolName}`,
690
+ },
691
+ ],
692
+ },
693
+ };
694
+ }
695
+
696
+ try {
697
+ const result = tool.handler(toolArgs);
698
+ return {
699
+ jsonrpc: "2.0",
700
+ id,
701
+ result: {
702
+ content: [
703
+ {
704
+ type: "text",
705
+ text: JSON.stringify(result, null, 2),
706
+ },
707
+ ],
708
+ },
709
+ };
710
+ } catch (err) {
711
+ return {
712
+ jsonrpc: "2.0",
713
+ id,
714
+ result: {
715
+ isError: true,
716
+ content: [
717
+ {
718
+ type: "text",
719
+ text: `Tool error: ${err.message}`,
720
+ },
721
+ ],
722
+ },
723
+ };
724
+ }
725
+ }
726
+
727
+ case "ping":
728
+ return { jsonrpc: "2.0", id, result: {} };
729
+
730
+ default:
731
+ if (id !== undefined) {
732
+ return {
733
+ jsonrpc: "2.0",
734
+ id,
735
+ error: {
736
+ code: -32601,
737
+ message: `Method not found: ${method}`,
738
+ },
739
+ };
740
+ }
741
+ return null;
742
+ }
743
+ }
744
+
745
+ // ──────────────────────────────────────────────
746
+ // stdio transport
747
+ // ──────────────────────────────────────────────
748
+
749
+ let buffer = "";
750
+
751
+ process.stdin.setEncoding("utf-8");
752
+ process.stdin.on("data", (chunk) => {
753
+ buffer += chunk;
754
+
755
+ let newlineIdx;
756
+ while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
757
+ const line = buffer.slice(0, newlineIdx).trim();
758
+ buffer = buffer.slice(newlineIdx + 1);
759
+
760
+ if (!line) continue;
761
+
762
+ let msg;
763
+ try {
764
+ msg = JSON.parse(line);
765
+ } catch {
766
+ const errResp = {
767
+ jsonrpc: "2.0",
768
+ id: null,
769
+ error: { code: -32700, message: "Parse error" },
770
+ };
771
+ process.stdout.write(JSON.stringify(errResp) + "\n");
772
+ continue;
773
+ }
774
+
775
+ const response = handleRequest(msg);
776
+ if (response) {
777
+ process.stdout.write(JSON.stringify(response) + "\n");
778
+ }
779
+ }
780
+ });
781
+
782
+ process.stdin.on("end", () => {
783
+ process.exit(0);
784
+ });
785
+
786
+ process.on("uncaughtException", (err) => {
787
+ process.stderr.write(`sticky-note MCP server error: ${err.message}\n`);
788
+ });