memory-journal-mcp 4.4.2 → 4.5.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 (73) hide show
  1. package/.github/workflows/lint-and-test.yml +1 -1
  2. package/.github/workflows/security-update.yml +1 -1
  3. package/CHANGELOG.md +81 -1
  4. package/DOCKER_README.md +57 -7
  5. package/Dockerfile +17 -17
  6. package/README.md +65 -6
  7. package/SECURITY.md +27 -35
  8. package/dist/cli.js +10 -0
  9. package/dist/cli.js.map +1 -1
  10. package/dist/constants/ServerInstructions.d.ts +5 -1
  11. package/dist/constants/ServerInstructions.d.ts.map +1 -1
  12. package/dist/constants/ServerInstructions.js +137 -83
  13. package/dist/constants/ServerInstructions.js.map +1 -1
  14. package/dist/database/SqliteAdapter.d.ts +2 -1
  15. package/dist/database/SqliteAdapter.d.ts.map +1 -1
  16. package/dist/database/SqliteAdapter.js +15 -8
  17. package/dist/database/SqliteAdapter.js.map +1 -1
  18. package/dist/handlers/resources/index.d.ts +3 -1
  19. package/dist/handlers/resources/index.d.ts.map +1 -1
  20. package/dist/handlers/resources/index.js +5 -2
  21. package/dist/handlers/resources/index.js.map +1 -1
  22. package/dist/handlers/tools/index.d.ts.map +1 -1
  23. package/dist/handlers/tools/index.js +63 -16
  24. package/dist/handlers/tools/index.js.map +1 -1
  25. package/dist/server/McpServer.d.ts +2 -0
  26. package/dist/server/McpServer.d.ts.map +1 -1
  27. package/dist/server/McpServer.js +43 -2
  28. package/dist/server/McpServer.js.map +1 -1
  29. package/dist/server/Scheduler.d.ts +91 -0
  30. package/dist/server/Scheduler.d.ts.map +1 -0
  31. package/dist/server/Scheduler.js +201 -0
  32. package/dist/server/Scheduler.js.map +1 -0
  33. package/dist/utils/logger.d.ts.map +1 -1
  34. package/dist/utils/logger.js +6 -3
  35. package/dist/utils/logger.js.map +1 -1
  36. package/dist/utils/security-utils.d.ts +0 -21
  37. package/dist/utils/security-utils.d.ts.map +1 -1
  38. package/dist/utils/security-utils.js +0 -47
  39. package/dist/utils/security-utils.js.map +1 -1
  40. package/hooks/README.md +107 -0
  41. package/hooks/cursor/hooks.json +10 -0
  42. package/hooks/cursor/memory-journal.mdc +22 -0
  43. package/hooks/cursor/session-end.sh +19 -0
  44. package/hooks/kilo-code/session-end-mode.json +11 -0
  45. package/hooks/kiro/session-end.md +13 -0
  46. package/package.json +8 -8
  47. package/releases/v4.5.0.md +116 -0
  48. package/scripts/generate-server-instructions.ts +176 -0
  49. package/scripts/server-instructions-function-body.ts +77 -0
  50. package/server.json +3 -3
  51. package/src/cli.ts +26 -0
  52. package/src/constants/ServerInstructions.ts +137 -83
  53. package/src/constants/server-instructions.md +262 -0
  54. package/src/database/SqliteAdapter.ts +22 -8
  55. package/src/handlers/resources/index.ts +8 -2
  56. package/src/handlers/tools/index.ts +70 -20
  57. package/src/server/McpServer.ts +60 -2
  58. package/src/server/Scheduler.ts +278 -0
  59. package/src/utils/logger.ts +6 -3
  60. package/src/utils/security-utils.ts +0 -52
  61. package/tests/constants/server-instructions.test.ts +26 -0
  62. package/tests/database/sqlite-adapter.test.ts +84 -0
  63. package/tests/filtering/tool-filter.test.ts +46 -0
  64. package/tests/handlers/github-resource-handlers.test.ts +453 -0
  65. package/tests/handlers/github-tool-handlers.test.ts +899 -0
  66. package/tests/handlers/prompt-handlers.test.ts +40 -0
  67. package/tests/handlers/resource-handlers.test.ts +32 -0
  68. package/tests/handlers/tool-handlers.test.ts +13 -2
  69. package/tests/security/sql-injection.test.ts +3 -54
  70. package/tests/server/mcp-server.test.ts +491 -5
  71. package/tests/server/scheduler.test.ts +400 -0
  72. package/tests/vector/vector-search-manager.test.ts +60 -0
  73. package/.vscode/settings.json +0 -84
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Memory Journal MCP Server - Scheduler
3
+ *
4
+ * Lightweight in-process scheduler for periodic maintenance jobs.
5
+ * Only meaningful for HTTP/SSE transport (long-lived server processes).
6
+ * Uses setInterval for simplicity — no external dependencies.
7
+ */
8
+
9
+ import type { SqliteAdapter } from '../database/SqliteAdapter.js'
10
+ import type { VectorSearchManager } from '../vector/VectorSearchManager.js'
11
+ import { logger } from '../utils/logger.js'
12
+
13
+ // ============================================================================
14
+ // Types
15
+ // ============================================================================
16
+
17
+ /** Scheduler configuration options */
18
+ export interface SchedulerOptions {
19
+ /** Automated backup interval in minutes (0 = disabled) */
20
+ backupIntervalMinutes: number
21
+ /** Max backups to retain during automated cleanup */
22
+ keepBackups: number
23
+ /** Database optimize interval in minutes (0 = disabled) */
24
+ vacuumIntervalMinutes: number
25
+ /** Vector index rebuild interval in minutes (0 = disabled) */
26
+ rebuildIndexIntervalMinutes: number
27
+ }
28
+
29
+ /** Status of a single scheduled job */
30
+ export interface JobStatus {
31
+ name: string
32
+ enabled: boolean
33
+ intervalMinutes: number
34
+ lastRun: string | null
35
+ lastResult: 'success' | 'error' | null
36
+ lastError: string | null
37
+ nextRun: string | null
38
+ runCount: number
39
+ }
40
+
41
+ /** Overall scheduler status */
42
+ export interface SchedulerStatus {
43
+ active: boolean
44
+ jobs: JobStatus[]
45
+ }
46
+
47
+ /** Internal timer tracking for a job */
48
+ interface JobTimer {
49
+ name: string
50
+ intervalMinutes: number
51
+ timer: ReturnType<typeof setInterval>
52
+ lastRun: Date | null
53
+ lastResult: 'success' | 'error' | null
54
+ lastError: string | null
55
+ runCount: number
56
+ }
57
+
58
+ // ============================================================================
59
+ // Scheduler
60
+ // ============================================================================
61
+
62
+ /**
63
+ * Scheduler — runs periodic maintenance jobs for long-lived server processes.
64
+ *
65
+ * Jobs:
66
+ * - **backup**: Exports database to timestamped file, then prunes old backups.
67
+ * - **vacuum**: Runs `PRAGMA optimize` and flushes database to disk.
68
+ * - **rebuild-index**: Rebuilds vector search index from all entries.
69
+ */
70
+ export class Scheduler {
71
+ private readonly options: SchedulerOptions
72
+ private readonly db: SqliteAdapter
73
+ private readonly vectorManager: VectorSearchManager | null
74
+ private readonly timers: JobTimer[] = []
75
+ private started = false
76
+
77
+ constructor(options: SchedulerOptions, db: SqliteAdapter, vectorManager?: VectorSearchManager) {
78
+ this.options = options
79
+ this.db = db
80
+ this.vectorManager = vectorManager ?? null
81
+ }
82
+
83
+ /**
84
+ * Start all enabled scheduled jobs.
85
+ * Each job runs on its own interval and failures are isolated.
86
+ */
87
+ start(): void {
88
+ if (this.started) {
89
+ logger.warning('Scheduler already started, ignoring duplicate start()', {
90
+ module: 'Scheduler',
91
+ })
92
+ return
93
+ }
94
+ this.started = true
95
+
96
+ const { backupIntervalMinutes, vacuumIntervalMinutes, rebuildIndexIntervalMinutes } =
97
+ this.options
98
+
99
+ if (backupIntervalMinutes > 0) {
100
+ this.scheduleJob('backup', backupIntervalMinutes, () => this.runBackup())
101
+ }
102
+
103
+ if (vacuumIntervalMinutes > 0) {
104
+ this.scheduleJob('vacuum', vacuumIntervalMinutes, () => this.runVacuumOptimize())
105
+ }
106
+
107
+ if (rebuildIndexIntervalMinutes > 0) {
108
+ if (this.vectorManager) {
109
+ this.scheduleJob('rebuild-index', rebuildIndexIntervalMinutes, () =>
110
+ this.runRebuildIndex()
111
+ )
112
+ } else {
113
+ logger.warning(
114
+ 'rebuild-index-interval specified but vector manager not available, skipping',
115
+ { module: 'Scheduler' }
116
+ )
117
+ }
118
+ }
119
+
120
+ if (this.timers.length > 0) {
121
+ const summary = this.timers.map((t) => `${t.name} (${String(t.intervalMinutes)}min)`)
122
+ logger.info(`Scheduler started: ${summary.join(', ')}`, { module: 'Scheduler' })
123
+ } else {
124
+ logger.info('Scheduler started with no jobs enabled', { module: 'Scheduler' })
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Stop all scheduled jobs and clear timers.
130
+ * Safe to call multiple times.
131
+ */
132
+ stop(): void {
133
+ for (const job of this.timers) {
134
+ clearInterval(job.timer)
135
+ }
136
+ if (this.timers.length > 0) {
137
+ logger.info(`Scheduler stopped, cleared ${String(this.timers.length)} job(s)`, {
138
+ module: 'Scheduler',
139
+ })
140
+ }
141
+ this.timers.length = 0
142
+ this.started = false
143
+ }
144
+
145
+ /**
146
+ * Get the current status of all scheduled jobs.
147
+ */
148
+ getStatus(): SchedulerStatus {
149
+ return {
150
+ active: this.started,
151
+ jobs: this.timers.map((t) => ({
152
+ name: t.name,
153
+ enabled: true,
154
+ intervalMinutes: t.intervalMinutes,
155
+ lastRun: t.lastRun?.toISOString() ?? null,
156
+ lastResult: t.lastResult,
157
+ lastError: t.lastError,
158
+ nextRun: t.lastRun
159
+ ? new Date(t.lastRun.getTime() + t.intervalMinutes * 60_000).toISOString()
160
+ : new Date(Date.now() + t.intervalMinutes * 60_000).toISOString(),
161
+ runCount: t.runCount,
162
+ })),
163
+ }
164
+ }
165
+
166
+ // ========================================================================
167
+ // Private — Job scheduling
168
+ // ========================================================================
169
+
170
+ /**
171
+ * Schedule a recurring job.
172
+ */
173
+ private scheduleJob(name: string, intervalMinutes: number, fn: () => Promise<void>): void {
174
+ const intervalMs = intervalMinutes * 60_000
175
+
176
+ const jobTimer: JobTimer = {
177
+ name,
178
+ intervalMinutes,
179
+ timer: setInterval(() => {
180
+ void this.executeJob(jobTimer, fn)
181
+ }, intervalMs),
182
+ lastRun: null,
183
+ lastResult: null,
184
+ lastError: null,
185
+ runCount: 0,
186
+ }
187
+
188
+ this.timers.push(jobTimer)
189
+ }
190
+
191
+ /**
192
+ * Execute a job with error isolation and status tracking.
193
+ */
194
+ private async executeJob(job: JobTimer, fn: () => Promise<void>): Promise<void> {
195
+ const startTime = Date.now()
196
+ try {
197
+ await fn()
198
+ job.lastRun = new Date(startTime)
199
+ job.lastResult = 'success'
200
+ job.lastError = null
201
+ job.runCount++
202
+ } catch (error) {
203
+ job.lastRun = new Date(startTime)
204
+ job.lastResult = 'error'
205
+ job.lastError = error instanceof Error ? error.message : String(error)
206
+ job.runCount++
207
+ logger.error(`Scheduled job '${job.name}' failed`, {
208
+ module: 'Scheduler',
209
+ operation: job.name,
210
+ error: job.lastError,
211
+ })
212
+ }
213
+ }
214
+
215
+ // ========================================================================
216
+ // Private — Job implementations
217
+ // ========================================================================
218
+
219
+ /**
220
+ * Backup job: export database to file, then cleanup old backups.
221
+ */
222
+ private async runBackup(): Promise<void> {
223
+ const result = this.db.exportToFile()
224
+ logger.info('Scheduled backup created', {
225
+ module: 'Scheduler',
226
+ operation: 'backup',
227
+ context: { filename: result.filename, sizeBytes: result.sizeBytes },
228
+ })
229
+
230
+ const cleanup = this.db.deleteOldBackups(this.options.keepBackups)
231
+ if (cleanup.deleted.length > 0) {
232
+ logger.info(
233
+ `Backup cleanup: deleted ${String(cleanup.deleted.length)}, kept ${String(cleanup.kept)}`,
234
+ {
235
+ module: 'Scheduler',
236
+ operation: 'backup-cleanup',
237
+ }
238
+ )
239
+ }
240
+
241
+ await Promise.resolve()
242
+ }
243
+
244
+ /**
245
+ * Vacuum/optimize job: run PRAGMA optimize and flush to disk.
246
+ *
247
+ * Note: sql.js uses an in-memory database. PRAGMA optimize updates
248
+ * internal statistics, and flushSave() ensures the disk file is current.
249
+ * A full VACUUM on sql.js only compacts the in-memory representation.
250
+ */
251
+ private async runVacuumOptimize(): Promise<void> {
252
+ const rawDb = this.db.getRawDb()
253
+ rawDb.run('PRAGMA optimize')
254
+ this.db.flushSave()
255
+ logger.info('Scheduled database optimize completed', {
256
+ module: 'Scheduler',
257
+ operation: 'vacuum',
258
+ })
259
+
260
+ await Promise.resolve()
261
+ }
262
+
263
+ /**
264
+ * Rebuild index job: full vector index rebuild from database entries.
265
+ */
266
+ private async runRebuildIndex(): Promise<void> {
267
+ if (!this.vectorManager) {
268
+ return
269
+ }
270
+
271
+ const count = await this.vectorManager.rebuildIndex(this.db)
272
+ logger.info(`Scheduled vector index rebuild: ${String(count)} entries indexed`, {
273
+ module: 'Scheduler',
274
+ operation: 'rebuild-index',
275
+ context: { entriesIndexed: count },
276
+ })
277
+ }
278
+ }
@@ -97,11 +97,14 @@ class Logger {
97
97
  }
98
98
 
99
99
  setLevel(level: LogLevel): void {
100
- this.minLevel = LOG_LEVELS[level]
100
+ if (level in LOG_LEVELS) {
101
+ this.minLevel = LOG_LEVELS[level]
102
+ }
101
103
  }
102
104
  }
103
105
 
104
- // Get log level from environment
105
- const envLevel = (process.env['LOG_LEVEL'] ?? 'info') as LogLevel
106
+ // Get log level from environment (validated against known levels)
107
+ const rawLevel = process.env['LOG_LEVEL'] ?? 'info'
108
+ const envLevel: LogLevel = rawLevel in LOG_LEVELS ? (rawLevel as LogLevel) : 'info'
106
109
 
107
110
  export const logger = new Logger(envLevel)
@@ -32,16 +32,6 @@ export class InvalidDateFormatError extends SecurityError {
32
32
  }
33
33
  }
34
34
 
35
- /**
36
- * Thrown when SQL injection patterns are detected in input
37
- */
38
- export class SqlInjectionError extends SecurityError {
39
- constructor(pattern: string) {
40
- super(`Potential SQL injection detected: '${pattern}'`, 'SQL_INJECTION')
41
- this.name = 'SqlInjectionError'
42
- }
43
- }
44
-
45
35
  /**
46
36
  * Thrown when path traversal is detected in input
47
37
  */
@@ -111,48 +101,6 @@ export function sanitizeSearchQuery(query: string): string {
111
101
  return query.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_')
112
102
  }
113
103
 
114
- // ============================================================================
115
- // SQL Injection Detection
116
- // ============================================================================
117
-
118
- /**
119
- * Patterns that indicate SQL injection attempts.
120
- * Used for validation in edge cases where parameterized queries aren't possible.
121
- */
122
- const SQL_INJECTION_PATTERNS = [
123
- /;\s*(DROP|DELETE|INSERT|UPDATE|CREATE|ALTER|TRUNCATE)/i,
124
- /--\s*/,
125
- /\/\*[\s\S]*?\*\//,
126
- /UNION\s+(ALL\s+)?SELECT/i,
127
- /'\s*OR\s+['"]?1['"]?\s*=\s*['"]?1/i,
128
- /ATTACH\s+DATABASE/i,
129
- /DETACH\s+DATABASE/i,
130
- /load_extension\s*\(/i,
131
- ] as const
132
-
133
- /**
134
- * Checks if a string contains potential SQL injection patterns.
135
- * This is a secondary defense layer; parameterized queries are the primary defense.
136
- *
137
- * @param input - The string to check
138
- * @returns true if injection patterns are detected, false otherwise
139
- */
140
- export function containsSqlInjection(input: string): boolean {
141
- return SQL_INJECTION_PATTERNS.some((pattern) => pattern.test(input))
142
- }
143
-
144
- /**
145
- * Validates that input does not contain SQL injection patterns.
146
- *
147
- * @param input - The string to validate
148
- * @throws SqlInjectionError if injection patterns are detected
149
- */
150
- export function assertNoSqlInjection(input: string): void {
151
- if (containsSqlInjection(input)) {
152
- throw new SqlInjectionError(input.substring(0, 50))
153
- }
154
- }
155
-
156
104
  // ============================================================================
157
105
  // Path Validation
158
106
  // ============================================================================
@@ -99,6 +99,32 @@ describe('generateInstructions', () => {
99
99
  )
100
100
  expect(result).not.toContain('## GitHub Integration')
101
101
  })
102
+
103
+ it('should include Session End section', () => {
104
+ const result = generateInstructions(
105
+ TEST_TOOLS,
106
+ TEST_RESOURCES,
107
+ TEST_PROMPTS,
108
+ undefined,
109
+ 'essential'
110
+ )
111
+ expect(result).toContain('Session End')
112
+ expect(result).toContain('session-summary')
113
+ expect(result).toContain('retrospective')
114
+ expect(result).toContain('opt-out')
115
+ })
116
+
117
+ it('should include Rule & Skill Suggestions section', () => {
118
+ const result = generateInstructions(
119
+ TEST_TOOLS,
120
+ TEST_RESOURCES,
121
+ TEST_PROMPTS,
122
+ undefined,
123
+ 'essential'
124
+ )
125
+ expect(result).toContain('Rule & Skill Suggestions')
126
+ expect(result).toContain('always ask the user first')
127
+ })
102
128
  })
103
129
 
104
130
  describe('standard level', () => {
@@ -471,6 +471,65 @@ describe('SqliteAdapter', () => {
471
471
  fs.unlinkSync(backup.path)
472
472
  }
473
473
  })
474
+
475
+ it('should delete old backups keeping only keepCount', () => {
476
+ const fs = require('node:fs')
477
+
478
+ // Clean up any pre-existing backups from other tests
479
+ const preExisting = db.listBackups()
480
+ for (const backup of preExisting) {
481
+ if (fs.existsSync(backup.path)) fs.unlinkSync(backup.path)
482
+ }
483
+
484
+ // Create 3 backups
485
+ const b1 = db.exportToFile('cleanup-1')
486
+ const b2 = db.exportToFile('cleanup-2')
487
+ const b3 = db.exportToFile('cleanup-3')
488
+
489
+ // Keep only 1 newest
490
+ db.deleteOldBackups(1)
491
+
492
+ const remaining = db.listBackups()
493
+ // Should have exactly 1 backup remaining (newest)
494
+ expect(remaining.length).toBe(1)
495
+
496
+ // Cleanup any remaining
497
+ for (const path of [b1.path, b2.path, b3.path]) {
498
+ if (fs.existsSync(path)) fs.unlinkSync(path)
499
+ }
500
+ })
501
+
502
+ it('should restore from a backup file', async () => {
503
+ const fs = require('node:fs')
504
+ // Create an entry and backup
505
+ db.createEntry({ content: 'Before restore test' })
506
+ const countBefore = db.getActiveEntryCount()
507
+ const backup = db.exportToFile('restore-test')
508
+
509
+ // Create more entries after backup
510
+ db.createEntry({ content: 'After backup 1' })
511
+ db.createEntry({ content: 'After backup 2' })
512
+ const countAfterAdding = db.getActiveEntryCount()
513
+ expect(countAfterAdding).toBeGreaterThan(countBefore)
514
+
515
+ // Restore should revert to backup state
516
+ const result = await db.restoreFromFile(backup.filename)
517
+ expect(result.previousEntryCount).toBe(countAfterAdding)
518
+ expect(result.newEntryCount).toBe(countBefore)
519
+
520
+ // Cleanup
521
+ const backups = db.listBackups()
522
+ for (const b of backups) {
523
+ const path = require('node:path').join('backups', b.filename)
524
+ if (fs.existsSync(path)) fs.unlinkSync(path)
525
+ }
526
+ })
527
+
528
+ it('should get raw database handle', () => {
529
+ const rawDb = db.getRawDb()
530
+ expect(rawDb).toBeDefined()
531
+ expect(typeof rawDb.exec).toBe('function')
532
+ })
474
533
  })
475
534
 
476
535
  // ========================================================================
@@ -552,4 +611,29 @@ describe('SqliteAdapter', () => {
552
611
  expect(results.length).toBeGreaterThan(0)
553
612
  })
554
613
  })
614
+
615
+ // ========================================================================
616
+ // Backup edge cases
617
+ // ========================================================================
618
+
619
+ describe('backup edge cases', () => {
620
+ it('should return empty array when backups directory does not exist', () => {
621
+ // Use a fresh adapter with no backups dir created
622
+ const tempDb = new SqliteAdapter('./test-no-backups.db')
623
+ tempDb.initialize()
624
+ const backups = tempDb.listBackups()
625
+ expect(backups).toEqual([])
626
+ tempDb.close()
627
+ })
628
+
629
+ it('should throw when deleteOldBackups keepCount is less than 1', () => {
630
+ expect(() => db.deleteOldBackups(0)).toThrow('keepCount must be at least 1')
631
+ })
632
+
633
+ it('should throw when restoring from non-existent backup file', async () => {
634
+ await expect(db.restoreFromFile('nonexistent-backup.db')).rejects.toThrow(
635
+ 'Backup file not found'
636
+ )
637
+ })
638
+ })
555
639
  })
@@ -264,3 +264,49 @@ describe('getFilterSummary', () => {
264
264
  expect(summary.length).toBeGreaterThan(0)
265
265
  })
266
266
  })
267
+
268
+ // ============================================================================
269
+ // parseToolFilter edge cases
270
+ // ============================================================================
271
+
272
+ describe('parseToolFilter edge cases', () => {
273
+ it('should handle blacklist-first mode (-admin = all except admin)', () => {
274
+ const config = parseToolFilter('-admin')
275
+ // Should have all tools EXCEPT admin tools
276
+ expect(config.enabledTools.has('create_entry')).toBe(true)
277
+ expect(config.enabledTools.has('search_entries')).toBe(true)
278
+ expect(config.enabledTools.has('backup_journal')).toBe(true)
279
+ // Admin tools should be excluded
280
+ expect(config.enabledTools.has('delete_entry')).toBe(false)
281
+ })
282
+
283
+ it('should handle single tool name whitelist', () => {
284
+ const config = parseToolFilter('create_entry')
285
+ expect(config.enabledTools.has('create_entry')).toBe(true)
286
+ expect(config.enabledTools.size).toBe(1)
287
+ })
288
+
289
+ it('should handle meta-group in non-first position', () => {
290
+ const config = parseToolFilter('backup,starter')
291
+ // starter = core + search, plus backup group
292
+ expect(config.enabledTools.has('create_entry')).toBe(true)
293
+ expect(config.enabledTools.has('search_entries')).toBe(true)
294
+ expect(config.enabledTools.has('backup_journal')).toBe(true)
295
+ })
296
+
297
+ it('should handle combined meta-group with tool exclusion', () => {
298
+ const config = parseToolFilter('starter,-create_entry_minimal')
299
+ expect(config.enabledTools.has('create_entry')).toBe(true)
300
+ expect(config.enabledTools.has('create_entry_minimal')).toBe(false)
301
+ })
302
+
303
+ it('should handle multiple exclusions', () => {
304
+ const config = parseToolFilter('full,-admin,-backup,-github')
305
+ expect(config.enabledTools.has('create_entry')).toBe(true)
306
+ expect(config.enabledTools.has('search_entries')).toBe(true)
307
+ // Excluded groups
308
+ expect(config.enabledTools.has('delete_entry')).toBe(false)
309
+ expect(config.enabledTools.has('backup_journal')).toBe(false)
310
+ expect(config.enabledTools.has('get_github_issues')).toBe(false)
311
+ })
312
+ })