engram-mcp-server 1.2.0

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.
Files changed (44) hide show
  1. package/README.md +645 -0
  2. package/dist/constants.d.ts +21 -0
  3. package/dist/constants.js +81 -0
  4. package/dist/database.d.ts +30 -0
  5. package/dist/database.js +134 -0
  6. package/dist/index.d.ts +3 -0
  7. package/dist/index.js +67 -0
  8. package/dist/migrations.d.ts +4 -0
  9. package/dist/migrations.js +342 -0
  10. package/dist/scripts/install-hooks.d.ts +3 -0
  11. package/dist/scripts/install-hooks.js +89 -0
  12. package/dist/tools/intelligence.d.ts +3 -0
  13. package/dist/tools/intelligence.js +427 -0
  14. package/dist/tools/maintenance.d.ts +3 -0
  15. package/dist/tools/maintenance.js +646 -0
  16. package/dist/tools/memory.d.ts +3 -0
  17. package/dist/tools/memory.js +446 -0
  18. package/dist/tools/scheduler.d.ts +3 -0
  19. package/dist/tools/scheduler.js +363 -0
  20. package/dist/tools/sessions.d.ts +3 -0
  21. package/dist/tools/sessions.js +355 -0
  22. package/dist/tools/tasks.d.ts +3 -0
  23. package/dist/tools/tasks.js +206 -0
  24. package/dist/types.d.ts +170 -0
  25. package/dist/types.js +5 -0
  26. package/dist/utils.d.ts +58 -0
  27. package/dist/utils.js +190 -0
  28. package/docs/scheduled-events.md +150 -0
  29. package/package.json +43 -0
  30. package/scripts/install-mcp.js +175 -0
  31. package/src/constants.ts +86 -0
  32. package/src/database.ts +162 -0
  33. package/src/index.ts +79 -0
  34. package/src/migrations.ts +367 -0
  35. package/src/scripts/install-hooks.ts +96 -0
  36. package/src/tools/intelligence.ts +469 -0
  37. package/src/tools/maintenance.ts +783 -0
  38. package/src/tools/memory.ts +543 -0
  39. package/src/tools/scheduler.ts +413 -0
  40. package/src/tools/sessions.ts +430 -0
  41. package/src/tools/tasks.ts +215 -0
  42. package/src/types.ts +267 -0
  43. package/src/utils.ts +216 -0
  44. package/tsconfig.json +19 -0
@@ -0,0 +1,543 @@
1
+ // ============================================================================
2
+ // Engram MCP Server — Core Memory Tools
3
+ // ============================================================================
4
+
5
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import { z } from "zod";
7
+ import { getDb, now, getCurrentSessionId } from "../database.js";
8
+ import { TOOL_PREFIX } from "../constants.js";
9
+ import type { ChangeRow, DecisionRow, FileNoteRow, ConventionRow } from "../types.js";
10
+
11
+ export function registerMemoryTools(server: McpServer): void {
12
+ // ═══════════════════════════════════════════════════════════════════════
13
+ // CHANGE TRACKING
14
+ // ═══════════════════════════════════════════════════════════════════════
15
+
16
+ server.registerTool(
17
+ `${TOOL_PREFIX}_record_change`,
18
+ {
19
+ title: "Record Change",
20
+ description: `Record a file change so future sessions know what happened and why. Call this after making significant modifications. Bulk recording is supported — pass multiple changes at once.
21
+
22
+ Args:
23
+ - changes (array): Array of change objects, each with:
24
+ - file_path (string): Relative path to the changed file
25
+ - change_type: "created" | "modified" | "deleted" | "refactored" | "renamed" | "moved" | "config_changed"
26
+ - description (string): What was changed and why
27
+ - diff_summary (string, optional): Brief summary of the diff
28
+ - impact_scope: "local" | "module" | "cross_module" | "global" (default: "local")
29
+
30
+ Returns:
31
+ Confirmation with number of changes recorded.`,
32
+ inputSchema: {
33
+ changes: z.array(z.object({
34
+ file_path: z.string().describe("Relative path to the changed file"),
35
+ change_type: z.enum(["created", "modified", "deleted", "refactored", "renamed", "moved", "config_changed"]),
36
+ description: z.string().describe("What was changed and why"),
37
+ diff_summary: z.string().optional().describe("Brief diff summary"),
38
+ impact_scope: z.enum(["local", "module", "cross_module", "global"]).default("local"),
39
+ })).min(1).describe("Array of changes to record"),
40
+ },
41
+ annotations: {
42
+ readOnlyHint: false,
43
+ destructiveHint: false,
44
+ idempotentHint: false,
45
+ openWorldHint: false,
46
+ },
47
+ },
48
+ async ({ changes }) => {
49
+ const db = getDb();
50
+ const timestamp = now();
51
+ const sessionId = getCurrentSessionId();
52
+
53
+ const insert = db.prepare(
54
+ "INSERT INTO changes (session_id, timestamp, file_path, change_type, description, diff_summary, impact_scope) VALUES (?, ?, ?, ?, ?, ?, ?)"
55
+ );
56
+
57
+ const transaction = db.transaction(() => {
58
+ for (const c of changes) {
59
+ insert.run(sessionId, timestamp, c.file_path, c.change_type, c.description, c.diff_summary || null, c.impact_scope);
60
+
61
+ // Auto-update file_notes last_modified_session
62
+ if (sessionId) {
63
+ db.prepare(
64
+ "UPDATE file_notes SET last_modified_session = ? WHERE file_path = ?"
65
+ ).run(sessionId, c.file_path);
66
+ }
67
+ }
68
+ });
69
+
70
+ transaction();
71
+
72
+ return {
73
+ content: [{
74
+ type: "text",
75
+ text: `Recorded ${changes.length} change(s) in session #${sessionId ?? "none"}.`,
76
+ }],
77
+ };
78
+ }
79
+ );
80
+
81
+ server.registerTool(
82
+ `${TOOL_PREFIX}_get_file_history`,
83
+ {
84
+ title: "Get File History",
85
+ description: `Get the complete change history for a specific file — all recorded modifications, related decisions, and file notes.
86
+
87
+ Args:
88
+ - file_path (string): Path to the file
89
+ - limit (number, optional): Max changes to return (default 20)
90
+
91
+ Returns:
92
+ File notes, change history, and related decisions.`,
93
+ inputSchema: {
94
+ file_path: z.string().describe("Relative path to the file"),
95
+ limit: z.number().int().min(1).max(100).default(20).describe("Max changes to return"),
96
+ },
97
+ annotations: {
98
+ readOnlyHint: true,
99
+ destructiveHint: false,
100
+ idempotentHint: true,
101
+ openWorldHint: false,
102
+ },
103
+ },
104
+ async ({ file_path, limit }) => {
105
+ const db = getDb();
106
+
107
+ const notes = db.prepare("SELECT * FROM file_notes WHERE file_path = ?").get(file_path) as unknown as FileNoteRow | undefined;
108
+ const changes = db.prepare(
109
+ "SELECT * FROM changes WHERE file_path = ? ORDER BY timestamp DESC LIMIT ?"
110
+ ).all(file_path, limit) as unknown[] as ChangeRow[];
111
+ const decisions = db.prepare(
112
+ "SELECT * FROM decisions WHERE affected_files LIKE ? AND status = 'active' ORDER BY timestamp DESC"
113
+ ).all(`%${file_path}%`) as unknown[] as DecisionRow[];
114
+
115
+ return {
116
+ content: [{
117
+ type: "text",
118
+ text: JSON.stringify({
119
+ file_path,
120
+ notes: notes || null,
121
+ change_count: changes.length,
122
+ changes,
123
+ related_decisions: decisions,
124
+ }, null, 2),
125
+ }],
126
+ };
127
+ }
128
+ );
129
+
130
+ // ═══════════════════════════════════════════════════════════════════════
131
+ // ARCHITECTURAL DECISIONS
132
+ // ═══════════════════════════════════════════════════════════════════════
133
+
134
+ server.registerTool(
135
+ `${TOOL_PREFIX}_record_decision`,
136
+ {
137
+ title: "Record Decision",
138
+ description: `Record an architectural or design decision with its rationale. These persist across all future sessions and are surfaced during start_session. Use this for any choice that future agents or sessions need to respect.
139
+
140
+ Args:
141
+ - decision (string): The decision that was made
142
+ - rationale (string, optional): Why this decision was made — context, tradeoffs, alternatives considered
143
+ - affected_files (array of strings, optional): Files impacted by this decision
144
+ - tags (array of strings, optional): Categorization tags (e.g., "architecture", "database", "ui", "api")
145
+ - status: "active" | "experimental" (default: "active")
146
+ - supersedes (number, optional): ID of a previous decision this replaces
147
+
148
+ Returns:
149
+ Decision ID and confirmation.`,
150
+ inputSchema: {
151
+ decision: z.string().min(5).describe("The decision that was made"),
152
+ rationale: z.string().optional().describe("Why — context, tradeoffs, alternatives considered"),
153
+ affected_files: z.array(z.string()).optional().describe("Files impacted by this decision"),
154
+ tags: z.array(z.string()).optional().describe("Tags for categorization"),
155
+ status: z.enum(["active", "experimental"]).default("active"),
156
+ supersedes: z.number().int().optional().describe("ID of a previous decision this replaces"),
157
+ },
158
+ annotations: {
159
+ readOnlyHint: false,
160
+ destructiveHint: false,
161
+ idempotentHint: false,
162
+ openWorldHint: false,
163
+ },
164
+ },
165
+ async ({ decision, rationale, affected_files, tags, status, supersedes }) => {
166
+ const db = getDb();
167
+ const timestamp = now();
168
+ const sessionId = getCurrentSessionId();
169
+
170
+ const result = db.prepare(
171
+ "INSERT INTO decisions (session_id, timestamp, decision, rationale, affected_files, tags, status, superseded_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
172
+ ).run(
173
+ sessionId, timestamp, decision,
174
+ rationale || null,
175
+ affected_files ? JSON.stringify(affected_files) : null,
176
+ tags ? JSON.stringify(tags) : null,
177
+ status,
178
+ supersedes || null
179
+ );
180
+
181
+ const newDecisionId = result.lastInsertRowid as number;
182
+
183
+ // If superseding, mark old decision with the new decision's ID
184
+ if (supersedes) {
185
+ db.prepare("UPDATE decisions SET status = 'superseded', superseded_by = ? WHERE id = ?")
186
+ .run(newDecisionId, supersedes);
187
+ }
188
+
189
+ return {
190
+ content: [{
191
+ type: "text",
192
+ text: JSON.stringify({
193
+ decision_id: newDecisionId,
194
+ message: `Decision #${newDecisionId} recorded${supersedes ? ` (supersedes #${supersedes})` : ""}.`,
195
+ decision,
196
+ }, null, 2),
197
+ }],
198
+ };
199
+ }
200
+ );
201
+
202
+ server.registerTool(
203
+ `${TOOL_PREFIX}_get_decisions`,
204
+ {
205
+ title: "Get Decisions",
206
+ description: `Retrieve recorded architectural decisions. Filter by status, tags, or affected files.
207
+
208
+ Args:
209
+ - status (string, optional): Filter by status — "active", "superseded", "deprecated", "experimental"
210
+ - tag (string, optional): Filter by tag
211
+ - file_path (string, optional): Find decisions affecting a specific file
212
+ - limit (number, optional): Max results (default 20)
213
+
214
+ Returns:
215
+ Array of decisions with rationale and metadata.`,
216
+ inputSchema: {
217
+ status: z.enum(["active", "superseded", "deprecated", "experimental"]).optional(),
218
+ tag: z.string().optional().describe("Filter by tag"),
219
+ file_path: z.string().optional().describe("Find decisions affecting this file"),
220
+ limit: z.number().int().min(1).max(100).default(20),
221
+ },
222
+ annotations: {
223
+ readOnlyHint: true,
224
+ destructiveHint: false,
225
+ idempotentHint: true,
226
+ openWorldHint: false,
227
+ },
228
+ },
229
+ async ({ status, tag, file_path, limit }) => {
230
+ const db = getDb();
231
+ let query = "SELECT * FROM decisions WHERE 1=1";
232
+ const params: unknown[] = [];
233
+
234
+ if (status) { query += " AND status = ?"; params.push(status); }
235
+ if (tag) { query += " AND EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?)"; params.push(tag); }
236
+ if (file_path) { query += " AND EXISTS (SELECT 1 FROM json_each(affected_files) WHERE value = ?)"; params.push(file_path); }
237
+
238
+ query += " ORDER BY timestamp DESC LIMIT ?";
239
+ params.push(limit);
240
+
241
+ const decisions = db.prepare(query).all(...params) as unknown[] as DecisionRow[];
242
+
243
+ return {
244
+ content: [{
245
+ type: "text",
246
+ text: JSON.stringify({ count: decisions.length, decisions }, null, 2),
247
+ }],
248
+ };
249
+ }
250
+ );
251
+
252
+ server.registerTool(
253
+ `${TOOL_PREFIX}_update_decision`,
254
+ {
255
+ title: "Update Decision Status",
256
+ description: `Update the status of an existing decision. Use to deprecate, supersede, or reactivate decisions.
257
+
258
+ Args:
259
+ - id (number): Decision ID to update
260
+ - status: "active" | "superseded" | "deprecated" | "experimental"
261
+
262
+ Returns:
263
+ Confirmation.`,
264
+ inputSchema: {
265
+ id: z.number().int().describe("Decision ID"),
266
+ status: z.enum(["active", "superseded", "deprecated", "experimental"]),
267
+ },
268
+ annotations: {
269
+ readOnlyHint: false,
270
+ destructiveHint: false,
271
+ idempotentHint: true,
272
+ openWorldHint: false,
273
+ },
274
+ },
275
+ async ({ id, status }) => {
276
+ const db = getDb();
277
+ const result = db.prepare("UPDATE decisions SET status = ? WHERE id = ?").run(status, id);
278
+ if (result.changes === 0) {
279
+ return { isError: true, content: [{ type: "text", text: `Decision #${id} not found.` }] };
280
+ }
281
+ return { content: [{ type: "text", text: `Decision #${id} status updated to "${status}".` }] };
282
+ }
283
+ );
284
+
285
+ // ═══════════════════════════════════════════════════════════════════════
286
+ // FILE NOTES
287
+ // ═══════════════════════════════════════════════════════════════════════
288
+
289
+ server.registerTool(
290
+ `${TOOL_PREFIX}_set_file_notes`,
291
+ {
292
+ title: "Set File Notes",
293
+ description: `Store persistent notes about a file: its purpose, dependencies, architectural layer, complexity, and any important details. This creates a knowledge base that eliminates the need to re-read and re-analyze files across sessions.
294
+
295
+ Args:
296
+ - file_path (string): Relative path to the file
297
+ - purpose (string, optional): What this file does — its responsibility
298
+ - dependencies (array, optional): Files this file depends on
299
+ - dependents (array, optional): Files that depend on this file
300
+ - layer: "ui" | "viewmodel" | "domain" | "data" | "network" | "database" | "di" | "util" | "test" | "config" | "build" | "other"
301
+ - complexity: "trivial" | "simple" | "moderate" | "complex" | "critical"
302
+ - notes (string, optional): Any important context, gotchas, or warnings
303
+
304
+ Returns:
305
+ Confirmation.`,
306
+ inputSchema: {
307
+ file_path: z.string().describe("Relative path to the file"),
308
+ purpose: z.string().optional().describe("What this file does"),
309
+ dependencies: z.array(z.string()).optional().describe("Files this depends on"),
310
+ dependents: z.array(z.string()).optional().describe("Files that depend on this"),
311
+ layer: z.enum(["ui", "viewmodel", "domain", "data", "network", "database", "di", "util", "test", "config", "build", "other"]).optional(),
312
+ complexity: z.enum(["trivial", "simple", "moderate", "complex", "critical"]).optional(),
313
+ notes: z.string().optional().describe("Important context, gotchas, warnings"),
314
+ },
315
+ annotations: {
316
+ readOnlyHint: false,
317
+ destructiveHint: false,
318
+ idempotentHint: true,
319
+ openWorldHint: false,
320
+ },
321
+ },
322
+ async ({ file_path, purpose, dependencies, dependents, layer, complexity, notes }) => {
323
+ const db = getDb();
324
+ const timestamp = now();
325
+ const sessionId = getCurrentSessionId();
326
+
327
+ db.prepare(`
328
+ INSERT INTO file_notes (file_path, purpose, dependencies, dependents, layer, last_reviewed, last_modified_session, notes, complexity)
329
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
330
+ ON CONFLICT(file_path) DO UPDATE SET
331
+ purpose = COALESCE(?, purpose),
332
+ dependencies = COALESCE(?, dependencies),
333
+ dependents = COALESCE(?, dependents),
334
+ layer = COALESCE(?, layer),
335
+ last_reviewed = ?,
336
+ last_modified_session = COALESCE(?, last_modified_session),
337
+ notes = COALESCE(?, notes),
338
+ complexity = COALESCE(?, complexity)
339
+ `).run(
340
+ file_path,
341
+ purpose || null,
342
+ dependencies ? JSON.stringify(dependencies) : null,
343
+ dependents ? JSON.stringify(dependents) : null,
344
+ layer || null,
345
+ timestamp,
346
+ sessionId,
347
+ notes || null,
348
+ complexity || null,
349
+ // Update values
350
+ purpose || null,
351
+ dependencies ? JSON.stringify(dependencies) : null,
352
+ dependents ? JSON.stringify(dependents) : null,
353
+ layer || null,
354
+ timestamp,
355
+ sessionId,
356
+ notes || null,
357
+ complexity || null,
358
+ );
359
+
360
+ return {
361
+ content: [{ type: "text", text: `File notes saved for ${file_path}.` }],
362
+ };
363
+ }
364
+ );
365
+
366
+ server.registerTool(
367
+ `${TOOL_PREFIX}_get_file_notes`,
368
+ {
369
+ title: "Get File Notes",
370
+ description: `Retrieve stored notes for one or more files. Use to quickly understand a file's purpose and context without reading it.
371
+
372
+ Args:
373
+ - file_path (string, optional): Specific file to query
374
+ - layer (string, optional): Filter by architectural layer
375
+ - complexity (string, optional): Filter by complexity level
376
+
377
+ Returns:
378
+ File notes with purpose, dependencies, layer, and complexity.`,
379
+ inputSchema: {
380
+ file_path: z.string().optional().describe("Specific file to query"),
381
+ layer: z.enum(["ui", "viewmodel", "domain", "data", "network", "database", "di", "util", "test", "config", "build", "other"]).optional(),
382
+ complexity: z.enum(["trivial", "simple", "moderate", "complex", "critical"]).optional(),
383
+ },
384
+ annotations: {
385
+ readOnlyHint: true,
386
+ destructiveHint: false,
387
+ idempotentHint: true,
388
+ openWorldHint: false,
389
+ },
390
+ },
391
+ async ({ file_path, layer, complexity }) => {
392
+ const db = getDb();
393
+
394
+ if (file_path) {
395
+ const note = db.prepare("SELECT * FROM file_notes WHERE file_path = ?").get(file_path);
396
+ return { content: [{ type: "text", text: JSON.stringify(note || { message: "No notes found for this file." }, null, 2) }] };
397
+ }
398
+
399
+ let query = "SELECT * FROM file_notes WHERE 1=1";
400
+ const params: unknown[] = [];
401
+ if (layer) { query += " AND layer = ?"; params.push(layer); }
402
+ if (complexity) { query += " AND complexity = ?"; params.push(complexity); }
403
+ query += " ORDER BY file_path";
404
+
405
+ const notes = db.prepare(query).all(...params) as unknown[] as FileNoteRow[];
406
+ return { content: [{ type: "text", text: JSON.stringify({ count: notes.length, files: notes }, null, 2) }] };
407
+ }
408
+ );
409
+
410
+ // ═══════════════════════════════════════════════════════════════════════
411
+ // CONVENTIONS
412
+ // ═══════════════════════════════════════════════════════════════════════
413
+
414
+ server.registerTool(
415
+ `${TOOL_PREFIX}_add_convention`,
416
+ {
417
+ title: "Add Convention",
418
+ description: `Record a project convention that the agent should always follow. Conventions are surfaced during start_session and serve as persistent rules.
419
+
420
+ Args:
421
+ - category: "naming" | "architecture" | "styling" | "testing" | "git" | "documentation" | "error_handling" | "performance" | "security" | "other"
422
+ - rule (string): The convention rule in clear, actionable language
423
+ - examples (array of strings, optional): Code or usage examples
424
+
425
+ Returns:
426
+ Convention ID and confirmation.`,
427
+ inputSchema: {
428
+ category: z.enum(["naming", "architecture", "styling", "testing", "git", "documentation", "error_handling", "performance", "security", "other"]),
429
+ rule: z.string().min(5).describe("The convention rule"),
430
+ examples: z.array(z.string()).optional().describe("Examples of the convention in use"),
431
+ },
432
+ annotations: {
433
+ readOnlyHint: false,
434
+ destructiveHint: false,
435
+ idempotentHint: false,
436
+ openWorldHint: false,
437
+ },
438
+ },
439
+ async ({ category, rule, examples }) => {
440
+ const db = getDb();
441
+ const timestamp = now();
442
+ const sessionId = getCurrentSessionId();
443
+
444
+ const result = db.prepare(
445
+ "INSERT INTO conventions (session_id, timestamp, category, rule, examples) VALUES (?, ?, ?, ?, ?)"
446
+ ).run(sessionId, timestamp, category, rule, examples ? JSON.stringify(examples) : null);
447
+
448
+ return {
449
+ content: [{
450
+ type: "text",
451
+ text: JSON.stringify({
452
+ convention_id: result.lastInsertRowid,
453
+ message: `Convention #${result.lastInsertRowid} added to [${category}].`,
454
+ rule,
455
+ }, null, 2),
456
+ }],
457
+ };
458
+ }
459
+ );
460
+
461
+ server.registerTool(
462
+ `${TOOL_PREFIX}_get_conventions`,
463
+ {
464
+ title: "Get Conventions",
465
+ description: `Retrieve all active project conventions. Optionally filter by category.
466
+
467
+ Args:
468
+ - category (string, optional): Filter by convention category
469
+ - include_disabled (boolean, optional): Include unenforced conventions (default: false)
470
+
471
+ Returns:
472
+ Array of conventions grouped by category.`,
473
+ inputSchema: {
474
+ category: z.enum(["naming", "architecture", "styling", "testing", "git", "documentation", "error_handling", "performance", "security", "other"]).optional(),
475
+ include_disabled: z.boolean().default(false),
476
+ },
477
+ annotations: {
478
+ readOnlyHint: true,
479
+ destructiveHint: false,
480
+ idempotentHint: true,
481
+ openWorldHint: false,
482
+ },
483
+ },
484
+ async ({ category, include_disabled }) => {
485
+ const db = getDb();
486
+ let query = "SELECT * FROM conventions WHERE 1=1";
487
+ const params: unknown[] = [];
488
+
489
+ if (!include_disabled) { query += " AND enforced = 1"; }
490
+ if (category) { query += " AND category = ?"; params.push(category); }
491
+ query += " ORDER BY category, id";
492
+
493
+ const conventions = db.prepare(query).all(...params) as unknown[] as ConventionRow[];
494
+
495
+ // Group by category
496
+ const grouped: Record<string, ConventionRow[]> = {};
497
+ for (const c of conventions) {
498
+ if (!grouped[c.category]) grouped[c.category] = [];
499
+ grouped[c.category].push(c);
500
+ }
501
+
502
+ return {
503
+ content: [{
504
+ type: "text",
505
+ text: JSON.stringify({ total: conventions.length, by_category: grouped }, null, 2),
506
+ }],
507
+ };
508
+ }
509
+ );
510
+
511
+ server.registerTool(
512
+ `${TOOL_PREFIX}_toggle_convention`,
513
+ {
514
+ title: "Toggle Convention",
515
+ description: `Enable or disable a convention. Disabled conventions are not surfaced during start_session.
516
+
517
+ Args:
518
+ - id (number): Convention ID
519
+ - enforced (boolean): Whether the convention should be enforced
520
+
521
+ Returns:
522
+ Confirmation.`,
523
+ inputSchema: {
524
+ id: z.number().int().describe("Convention ID"),
525
+ enforced: z.boolean().describe("Enable or disable"),
526
+ },
527
+ annotations: {
528
+ readOnlyHint: false,
529
+ destructiveHint: false,
530
+ idempotentHint: true,
531
+ openWorldHint: false,
532
+ },
533
+ },
534
+ async ({ id, enforced }) => {
535
+ const db = getDb();
536
+ const result = db.prepare("UPDATE conventions SET enforced = ? WHERE id = ?").run(enforced ? 1 : 0, id);
537
+ if (result.changes === 0) {
538
+ return { isError: true, content: [{ type: "text", text: `Convention #${id} not found.` }] };
539
+ }
540
+ return { content: [{ type: "text", text: `Convention #${id} ${enforced ? "enabled" : "disabled"}.` }] };
541
+ }
542
+ );
543
+ }